深色模式
Demo 5 · 无 API Key 跑通真实 Agent 全链路
目标:用
--patch覆盖层把上一章的 Mock 适配器挂进真实的dsh --profile headless进程,跑通 turn/step、会话日志、持久化的完整链路——不花一分钱、不发一个网络请求。这是本教程的分水岭。对应原理篇 03/05/07。
运行
sh
cd demos/05-headless-mock
DSH_HOME="$PWD/.dsh-home" npx dsh --profile headless --patch mock.patch.yml "你好,介绍一下你自己"预期输出:
text
(MockAdapter,无需网络与 API Key)收到你的消息:"你好,介绍一下你自己"。当前 provider=mock,model=mock-1。这一行字背后发生了什么?我们拆开看。
原理:patch 覆盖层做了两件事
mock.patch.yml 全文:
yaml
- insert:
- id: mock-adapter
name: ../../../plugins/mock-adapter.ts # ① 挂载本地插件
- id: agent-default-model
config:
provider: mock # ② 整行替换默认模型路由
model: mock-1启动时,dsh 在空 entry list 上依次叠加 dsh-base → dsh-headless → --patch 覆盖层。我们的覆盖层:
- insert 一行本地插件——Loader 从 profile 目录解析
../../../plugins/mock-adapter.ts,把MockAdapter注册进ctx.llm; - id 定向替换
agent-default-model行的 config——默认模型从deepseek-official/deepseek-v4-flash改成mock/mock-1。
于是 headless-runner 创建 Agent 时拿到 provider: mock,agent-loop 的每一次模型调用都被路由到你的 MockAdapter。整条真实链路——inbox、turn/step 状态机、系统提示词装配、会话日志、JSONL 持久化——原封不动地运转,只是模型换了。
验证:读真实的会话日志
headless-runner 退出前把会话 flush 到了 $DSH_HOME/sessions/(JSONL + zstd 压缩)。Demo 目录里附了一个读取脚本:
sh
node read-session.mjs节选输出:
text
会话日志:…/.dsh-home/sessions/…/session.jsonl.zstd
共 25 条记录
0 permission/preset {"preset":"workspace-write"}
1 sandbox/mode {"mode":"workspace-write"}
2 approval/policy {"policy":"ask"}
3 agent/inbox/spliced {"target":"next-turn",…}
4 turn/start {"turn":1}
6 step/start {"turn":1,"step":1}
7 user/message 「你好,介绍一下你自己」
8 user/message 「Current runtime context. …」
9 user/message 「<system-reminder> …skills…」
10 session/title {"title":"你好,介绍一下你自己",…}
11 request/header {"provider":"mock","model":"mock-1"}
14 assistant/chunk block-start #0
…
11122 assistant/chunk usage {"inputTokens":100,"outputTokens":20}
11123 assistant/chunk finish stop
11124 assistant/message 「(MockAdapter,无需网络与 API Key)收到你的消息:…」
11125 step/end {"turn":1,"step":1}
11126 turn/end {"kind":"completed"}这份日志信息量极大,逐条看:
- seq 0–2:会话建立时,策略插件先落盘了三条事实——权限预设、沙箱模式、审批策略。会话日志不只是"对话记录",它是会话的全部状态。
- seq 7 与 seq 8/9:你的任务只是第 7 条。第 8、9 条是 inject 的上下文——runtime context 快照与 skills 提醒。模型看到的输入比你的消息多,而这些多出来的东西全部可见、可审计。这就是"模型可见即已记录"铁律的现场。
- seq 10–13:会话标题生成。注意
session/title-llm-request的路由也是 mock——标题生成的辅助模型调用同样走我们的适配器。 - seq 14–11123:模型流式输出的每个 chunk 逐条落盘(含时间戳数组
dt,回放"打字机效果"的数据源)。 - seq 11124–11126:组装后的 assistant/message、step/end、turn/end
{kind: 'completed'}——turn 正常闭环。
一个调试技巧:dump 你的插件树
sh
DSH_HOME="$PWD/.dsh-home" npx dsh --profile headless --patch mock.patch.yml --dump-config | tail -8你会看到自己的 overlay 层:
yaml
# == …/mock.patch.yml
- id: mock-adapter
name: ../../../plugins/mock-adapter.ts
- id: agent-default-model
config:
provider: mock
model: mock-1--dump-config 打印的是组合后的最终配置树(不启动任何插件)——写 patch 时用它做静态检查,比跑起来再报错快得多。
亲手做实验
实验 1:换一个任务文本
任务改成"总结一下这个目录",观察 MockAdapter 输出的变化(它回显了你的任务)。
实验 2:看注入的上下文
把 plugins/mock-adapter.ts 里 messages.find 改成 [...messages].reverse().find(取最后一条 user 消息),重跑。这次你看到的就是 seq 9 的 skills 提醒——注入上下文就是一条 user-role 消息。
实验 3:制造一次错误
把 patch 里 agent-default-model 的 provider 改成 no-such-provider 重跑。你会看到 NO_ADAPTER 错误——请求被路由到不存在的适配器。再改回 mock 恢复。体会"配置即行为"。
常见错误
| 现象 | 原因 |
|---|---|
failed to import loader entry … Cannot find module | patch 里的相对路径算错了层级(baseUrl 是 profile 目录) |
MISSING_CREDENTIAL: llm-deepseek: no API key … | 忘打 --patch 或 patch 没生效,路由还是 deepseek-official |
duplicate adapter | 两个插件注册了同一个 provider 路由 |
| 输出与预期不符 | 检查 DSH_HOME 是否指向本 Demo 的 .dsh-home(会话/配置会被 home 里的旧 patch 影响) |
下一步
Demo 6 给这条真实链路加上工具:注册一个 echo 工具,让 Mock 适配器发起工具调用,观察两段式 step。