Skip to content

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-basedsh-headless--patch 覆盖层。我们的覆盖层:

  1. insert 一行本地插件——Loader 从 profile 目录解析 ../../../plugins/mock-adapter.ts,把 MockAdapter 注册进 ctx.llm
  2. 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"}

这份日志信息量极大,逐条看:

  1. seq 0–2:会话建立时,策略插件先落盘了三条事实——权限预设、沙箱模式、审批策略。会话日志不只是"对话记录",它是会话的全部状态。
  2. seq 7 与 seq 8/9:你的任务只是第 7 条。第 8、9 条是 inject 的上下文——runtime context 快照与 skills 提醒。模型看到的输入比你的消息多,而这些多出来的东西全部可见、可审计。这就是"模型可见即已记录"铁律的现场。
  3. seq 10–13:会话标题生成。注意 session/title-llm-request 的路由也是 mock——标题生成的辅助模型调用同样走我们的适配器。
  4. seq 14–11123:模型流式输出的每个 chunk 逐条落盘(含时间戳数组 dt,回放"打字机效果"的数据源)。
  5. 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.tsmessages.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 modulepatch 里的相对路径算错了层级(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。

基于 DeepSeek Harness(开发者预览版 0.1.0-rc.6)与 Cordis 撰写