第 9 章
组装:一个能修测试的 coding agent
装配不是把零件摆在一起,是看它们会不会互相咬。
第 1 章那个 200 行的 Agent,把测试改了。
现在,我们把第 3–8 章的零件装起来。
装完第一次跑,它确实修好了测试。但我打开会话文件,发现第一行不是用户任务,是 assistant 的回复。第二个问题更隐蔽:notes 在测试失败时也写了“tests pass”。第三个问题:最终回复在会话里出现了两次。
这一章不是“零件清单”,是“装配日志”:三个坑,怎么发现,怎么修。
9.1 第一个坑:会话里没有“用户要什么”
第一次装配,循环、工具、权限、上下文、笔记都接上了。跑 fix-due-date,测试绿了,输出也像模像样。
然后我打开 coding-session.jsonl:
{"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 回来,模型看到一串工具调用,但不知道用户最初让它干什么。
修复只有一行:
messages = [{"role": "user", "content": task}]
+ session.append({"role": "user", "content": task})这条也适用于所有“只追加”的日志:先写第一条,再开始循环。 不要等第一轮工具调用完再写;第一轮之前崩了,你连任务都没留下。
9.2 第二个坑:"OK" in res.text
原来的代码里,判断测试通过是这么写的:
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。但这是个定时炸弹:它不会在开发时炸,会在凌晨两点、测试失败、你正想睡觉的时候炸。
修复是检查最后一行,而不是全文:
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 消息都会写进会话:
assistant = {"role": "assistant", "content": [b.model_dump() for b in reply.content]}
messages.append(assistant)
session.append(assistant)然后在“没有工具调用”的退出分支,我又写了一次:
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 修完再跑:它第一次没有改测试
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这次会话是完整的:
- 第一条是用户任务;
- 中间是五轮工具调用和结果;
- 最后一条是 assistant 的最终回复;
- 没有重复。
coding-progress.md 里是:
# 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.py | 37 | 分层系统提示 |
tools.py | 195 | 工具注册表 + 六个工具 |
policy.py | 56 | 权限管线 |
session.py | 35 | 只追加会话 |
compact.py | 37 | 压缩器 |
notes.py | 33 | 进度笔记 |
coding_agent.py | 157 | 组装与循环 |
| 合计 | 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 跑一遍,然后恢复
cd code
python3 -m tiny.coding_agent --scripted它会改 taskflow/core.py,在 code/tiny/ 下生成 coding-progress.md 和 coding-session.jsonl。跑完恢复:
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。