合集

Claude Agent SDK 实战

已更新 27 章
阅读进度 37%

第 9 章

组装:一个能修测试的 coding agent

装配不是把零件摆在一起,是看它们会不会互相咬。

第 1 章那个 200 行的 Agent,把测试改了。

现在,我们把第 3–8 章的零件装起来。

装完第一次跑,它确实修好了测试。但我打开会话文件,发现第一行不是用户任务,是 assistant 的回复。第二个问题更隐蔽:notes 在测试失败时也写了“tests pass”。第三个问题:最终回复在会话里出现了两次。

这一章不是“零件清单”,是“装配日志”:三个坑,怎么发现,怎么修。


9.1 第一个坑:会话里没有“用户要什么”

第一次装配,循环、工具、权限、上下文、笔记都接上了。跑 fix-due-date,测试绿了,输出也像模像样。

然后我打开 coding-session.jsonl:

json
{"role": "assistant", "content": [{"type": "tool_use", ...}]}
{"role": "user", "content": [{"type": "tool_result", ...}]}
{"role": "assistant", "content": [{"type": "tool_use", ...}]}
...

第一行是 assistant,不是 user。

原因很简单:messages 里有初始任务,但 session 是空的;我只在每轮工具调用后 session.append,漏了最开始那条用户消息。

后果不是“看起来别扭”,是恢复会丢任务。你 resume 回来,模型看到一串工具调用,但不知道用户最初让它干什么。

修复只有一行:

diff
  messages = [{"role": "user", "content": task}]
+ session.append({"role": "user", "content": task})

这条也适用于所有“只追加”的日志:先写第一条,再开始循环。 不要等第一轮工具调用完再写;第一轮之前崩了,你连任务都没留下。


9.2 第二个坑:"OK" in res.text

原来的代码里,判断测试通过是这么写的:

python
if call.name == "run_tests" and "OK" in res.text:
    notes.add_done("tests pass")

看起来很合理:unittest 成功时最后一行是 OK。

但这是个子串判断。只要输出里任何地方出现 OK——测试方法名里带 OK、某行日志里有 OK、某个包名里有 OK——它就会写“tests pass”。

这次 demo 里没触发,是因为测试名刚好不叫 test_ok。但这是个定时炸弹:它不会在开发时炸,会在凌晨两点、测试失败、你正想睡觉的时候炸。

修复是检查最后一行,而不是全文:

python
if call.name == "run_tests":
    last = (res.text.strip().splitlines() or [""])[-1].strip()
    if last == "OK":
        notes.add_done("tests pass")

更好的做法是让工具返回结构化结果(退出码 + stdout),而不是让上层猜字符串。第 12 章讲权限、第 17 章讲评测时,都会回到这个原则:能用代码判断的,不要让模型或字符串猜。


9.3 第三个坑:最终回复写了两遍

循环里,每轮 assistant 消息都会写进会话:

python
assistant = {"role": "assistant", "content": [b.model_dump() for b in reply.content]}
messages.append(assistant)
session.append(assistant)

然后在“没有工具调用”的退出分支,我又写了一次:

python
if not calls:
    text = "".join(b.text for b in reply.content if b.type == "text")
    session.append({"role": "assistant", "content": text})   # 多余

assistant 消息里已经包含这段文本了;再 append 一次,会话最后就有两条一样的回复。

去掉那一行。加一条初始任务、删一条重复回复,会话还是 12 条——数量没变,内容对了。


9.4 修完再跑:它第一次没有改测试

text
mode: scripted (offline demo; not a real model output)
[context] {'system': 317, 'tools': 1680, 'task': 68, 'history': 0, 'total': 2065}
[tool] read_file {"path": "README.md"}
       -> - Run the tests before claiming done.
[tool] read_file {"path": "tests/test_core.py"}
       ->     unittest.main()
[tool] read_file {"path": "taskflow/core.py"}
       ->     return delta if delta > 0 else 1
[tool] edit_file {"path": "taskflow/core.py", "old": "return delta if delta > 0 else 1", "new": "return delta"}
       -> edited taskflow/core.py
[tool] run_tests {}
       -> OK
[notes] wrote coding-progress.md

assistant: 修复完成。days_left 在到期当天返回 0,两个测试都通过,测试文件没有动。
[session] 12 messages in coding-session.jsonl

这次会话是完整的:

  1. 第一条是用户任务;
  2. 中间是五轮工具调用和结果;
  3. 最后一条是 assistant 的最终回复;
  4. 没有重复。

coding-progress.md 里是:

text
# Progress

## Done
- [x] edited taskflow/core.py
- [x] tests pass

## Todo
- [ ] review the diff

它不聪明,但它有边界、有记忆、有验证。这就够了。


9.5 零件表:每个东西放在哪,放错了会怎样

零件位置放错会怎样
Context调模型前模型看不到规则,又开始改测试
default_registry每次 tool_use工具不存在/参数不对,循环卡住
Policy工具执行前拒绝无效,测试照样被改
SessionLog每条消息后resume 丢消息,包括初始任务
Compactor调模型前压坏 tool_use/tool_result 配对
Notes里程碑变成每轮写盘,慢且吵

装配不是“把代码 import 进来”,是“排顺序”。

这套代码实际有多大:

文件行数作用
context.py37分层系统提示
tools.py195工具注册表 + 六个工具
policy.py56权限管线
session.py35只追加会话
compact.py37压缩器
notes.py33进度笔记
coding_agent.py157组装与循环
合计550

五百多行,不是“几千行”。一个能修测试、有权限、有记忆、有会话的 coding agent,核心就这么大。真正的复杂度和风险,在边界条件、模型行为和评测上,不在行数上。

比如权限:它必须在 registry.run 之前。放在之后,工具已经执行了,你只是事后记了一笔“我不允许”,没有任何用。

比如会话:它必须在每条消息append之后、下一轮开始之前。放在循环结束,崩了就什么都没了。


9.6 和第 1 章那个 200 行差在哪

能力第 1 章的 200 行第 9 章
工具三个,没描述、没截断六个,带描述、错误回填、截断
权限没有tests/ 只读、敏感路径、命令白名单
上下文一句系统提示role + rules + volatile,分级组装
压缩没有超阈值压缩,保留任务/摘要/最近
记忆没有progress.md,可审计、可人工改
会话没有只追加 JSONL,可恢复/分叉
验证测试绿就算完测试通过 + 测试文件未变 + 进度记录

第 1 章那个 Agent 不是“笨”,它只是没有边界、没有记忆、没有验证。现在有了。


9.7 它还不是 Claude Code

别急着宣布胜利。它离 Claude Code 还有距离:

  • 计划:它不会先列 3–5 步再执行,容易一头扎进细节;
  • 待办:没有 todo 工具,长任务里你不知道它做到哪了;
  • diff 审查:它会改代码,但不会主动看“我改了哪些、有没有多余改动”;
  • 仓库地图:只会 list_files/search,没有“这个仓库大概长什么样”的上下文;
  • 子 Agent:第 8 章有 run_subagent,但还没接进 coding loop;
  • 评测:还没有一组任务来量它到底行不行;
  • 产品层:UI、审批界面、多租户、计费,都没有。

这些对应后面的章节:

  • 第 10–16 章:用 SDK 重写同一个 Agent;
  • 第 17–21 章:评测、成本、时延、可靠性、安全;
  • 第 22–25 章:三个业务 Agent + 设计清单。

9.8 Part II 复盘:如果重来,我会改三件事

第一,Day 1 就写 progress.md。

我们是第 7 章才加笔记的。前面几章一压缩就丢上下文,丢完再靠摘要补,绕了一圈。笔记很便宜,摘要很贵;应该一开始就写。

第二,先权限,后写工具。

第 4 章先加了 edit_file,第 5 章才加 Policy。中间那一章,Agent 是裸奔的。正确的顺序是:先有 tests/ 只读和命令白名单,再给它写工具。

第三,先做 trace/评测,再调提示词。

我们现在还没有一组固定任务来量“改完到底有没有变好”。第 17 章会补,但最好从第 3 章就开始记:同一个任务、同一个模型,成功/失败、轮数、成本。没有这个,后面调什么都是感觉。


9.9 跑一遍,然后恢复

bash
cd code
python3 -m tiny.coding_agent --scripted

它会改 taskflow/core.py,在 code/tiny/ 下生成 coding-progress.md 和 coding-session.jsonl。跑完恢复:

bash
git checkout -- code/lab/taskflow/taskflow/core.py
rm -f code/tiny/coding-progress.md code/tiny/coding-session.jsonl

想练手的话,把 9.1–9.3 的三个 bug 重新引入,再跑一次:先看会话第一行是不是 assistant,再看 progress.md 会不会在失败时写 pass,最后数一下会话末尾有没有两条一样的回复。


本章代码 组装后的 Agent:https://github.com/kevinkelin/claude-agent-sdk-book/blob/master/code/tiny/coding_agent.py 离线运行:cd code && python3 -m tiny.coding_agent --scripted 跑完恢复:git checkout -- code/lab/taskflow/taskflow/core.py 说明:--scripted 是离线替身,不是模型真实输出;真实模式需要 ANTHROPIC_API_KEY。