第 五 章 Agent 架构实操 深入学习 · 交互式 含 QA 测试

为什么上下文越长,系统提示词越没用

💡 会话级 todo_write:完整快照替换 + 三轮陈旧提醒。
长上下文里早期目标会被不断增长的局部信息稀释。本章把计划变成结构化状态:todo_write 每次返回完整 TODO 列表的稳定 JSON 快照;连续三轮其他工具没有更新计划时,下一次模型请求注入一条临时提醒。提醒只进当次请求,不污染正式历史。
本章进度
0%
1 本章导读与学习目标
  • 解释为什么长任务里丢的不是「目标」而是「进度」,以及为什么进度必须被显式记成结构化状态。
  • 用 Zod 定义一份唯一输入契约,写一个「整体替换」的 TODO 快照工具。
  • 说出「一口快照」为什么不需要回滚:校验全过才赋值,失败连新对象都不创建。
  • 说明为什么提醒必须只进当次请求、不污染正式历史,以及按什么计数。
  • 跑通第 5 章:让 Agent 先建立 TODO 再执行,观察提醒在第几次请求出现。
你需要先具备说明
读完第 1—4 章会看 Agent Loop、Zod、prepare/permission/invoke、Hook 即可
TypeScript能看懂 interface、readonly、Object.freeze
Transformer 常识知道「注意力检索」大致是什么。这一章不做数学推导
本章导读(你在这) ↓ ① 先看一眼真实的一次运行 ← 一次任务两条关键日志 ↓ ② 先明确:长上下文不是靠后权重大 ↓ ③ Zod 契约 + 完整快照 ← 一、二 ↓ ④ 稳定 JSON + 会话状态 ← 三、四 ↓ ⑤ 系统提示只管"什么时候用" + Nag 计数 ← 五、六 ↓ ⑥ 当次注入 + 副作用标签 ← 七、八 ↓ ⑦ 失败不破坏旧快照 + 离线证明 ← 九、十 ↓ ⑧ 运行 + 实验 + 差异 ← 十一~十四 ↓ ⑨ 小结 ← 三句话 + 七条 + 边界 + 自测
最该记住的一句长任务里丢的不是「目标」,是「进度」——目标在开头出现过一次,进度从来没被显式记录。
2 术语速查(本章第一次出现的词)
完整快照模型每次提交的整份 TODO 状态,整体替换上一份,不是增量 diff。
静态系统提示system 里一份固定文案;它只告诉模型「什么时候该更新」,不携带任何进度数据。
Nag Reminder按工具轮计数注入的「提醒你刷新 TODO」提示。只在当次请求出现。
漏写即删除整体替换没有隐式 merge:漏写一项 = 删除一项。
\uXXXX 转义返回的 JSON 里中文被转义(如 编写 → \u7f16\u5199),保证字节级稳定可断言。
会话状态todo 快照和陈旧计数都属于「会话」,做成单例会串台。
陈旧工具轮连续 N 次有工具调用但模型没有提交快照的轮次。
STALE_TOOL_ROUNDS陈旧阈值,默认 3;「计数到 3」和「注入提醒」不在同一次请求。
3 核心知识点
先明确:长上下文不是「越靠后权重越高」

很多人以为长上下文问题出在「开头被遗忘」。Transformer 的注意力核心是检索:能否检索到,取决于两点——信号强度(内容本身够不够「显著」)和显式记录(有没有被写进消息流)。进度之所以丢,不是位置靠前,而是它根本没有作为独立信号存在过。

对策不是把 system prompt 改长把更多规则塞进 system 只是增加「每轮不变的静态前缀」,改短了丢引导、改长了占注意力。真正的做法是:把进度做成结构化状态,让模型每轮都能「看见」一个最新快照。
一 · 用 Zod 定义唯一输入契约

todo_write 只有一个输入:整份采用列表。schema 用 z.strictObject + z.array + 各项 .refine 校验:status 只能是 pending / in_progress / done。用 transform 保证语义归一(如去空白后不能为空)。只保留这一个入口,模型就没法把「更新进度」理解成别的。

const TodosSchema = z.strictObject({
  todos: z.array(TodosItem),
});
// 模型必须提交整份 TODO,缺一项 = 删一项
二 · 一次提交必须是完整快照

整体替换没有隐式 merge:模型这次提交 3 项,下次只交 1 项,另外 2 项就没了。「漏写即删除」是刻意的——半更新比不更新更危险,至少模型能看见结果一致。校验全部通过后才做一次赋值,所以不存在半更新,失败也不需要回滚。

为什么还要冻结对象readonly 只管编译期,运行时靠 Object.freeze。实验二里严格模式会抛 TypeError,快照值不变。
三 · 返回稳定且完整的 JSON

工具结果必须是对模型「有用而且能复读」的完整快照:content 返回整份 {"todos":[...]},中文转成 \uXXXX(如 编写 → \u7f16\u5199)。字节级稳定让离线测试能精确断言,模型也能把结果原样喂回下一步。

{"todos":[{"content":"\u7f16\u5199 README","status":"done"}]}
四 · 状态必须属于会话,而不是模块

todo 快照和陈旧计数都是会话状态。如果做成 export const tracker = new TodoTracker() 的单例,两个不同会话跑同一个进程就会串台:A 会话的「三连陈旧」会触发 B 会话的提醒。状态必须从组合根按会话创建并注入。

串台示例单例 tracker 跨会话共享计数和快照,一次测试里两个 Agent 会互相看到对方的 TODO。
五、六 · 系统提示只管「什么时候用」;Nag 按工具轮计数

system 里一份固定文案只说明:有 todo_write 工具、它的入参是完整快照、什么时候该调用。它不携带任何进度数据(带了就破坏静态前缀 / KV Cache)。提醒计数按 assistant 消息算:一条 assistant 消息里三个工具调用只算一轮。计数到 3 和注入提醒不在同一次请求;注入后立刻清零。

STALE_TOOL_ROUNDS = 3
// 连续 3 个陈旧工具轮 → 下次请求注入提醒 → 立即清零
七 · 提醒只进当次请求,不污染正式历史

提醒加进 ModelRequest(本轮给模型的 messages),但不 push 进 #history。否则提醒会在轨迹里累积成过期提示,第二条任务轮到时就变成噪音。在 result.history 里搜不到 Keep the TODO list current,正是这个契约的证明。

request.messages.push(reminderSystemMessage)   // 当次请求
// history.push(...)  ← 这里没有
八 · write 副作用不等于磁盘写审批

todo_write 的 effect 是 write,但它只改会话内状态,不碰磁盘。规则精确匹配工具名而不是粗暴按 effect 匹配,所以 todos 更新不弹审批框;审计里有记录(任何最终决定都进审计),但无关磁盘安全。

九 · 失败为什么不会破坏旧快照

handler 里先校验、后赋值。模型提交 {"todos":[...]} 中有非法项时,Zod 在 prepare 阶段就拦下:handler 一次都没跑,旧快照原样保留(用 toBe 断言,失败路径连新对象都没创建)。

// 失败路径:handler 不跑、无副作用、旧快照 isEqual/to 不变
if (prepared.error) return prepared.error;   // invalid_arguments
十 · 离线测试如何证明这些契约
测试文件验证内容
todos.test.tsZod 契约、整体替换、冻结、会话隔离、按 round 计数、提醒注入时机
ch05-*.test.ts端到端:提醒出现在正确的模型请求、不进历史、失败不破坏旧快照
4 先看一眼真实的一次运行
一次成功的快照提交模型调用 todo_write 后,工具把整份 TODO 以 tool result 回填,消息历史里从此就有了一份「最新进度」——这就是模型的显式记忆。
提醒到底在第几次请求出现
请求1  模型连续调用工具(读文件)            陈旧计数 0→1
请求2  模型又调工具,没提交快照            陈旧计数 1→2
请求3  模型又调工具,没提交快照            陈旧计数 2→3
请求4  beforeModel: 计数已到 3 → 注入提醒,清零   ← 第 4 次请求看到提醒
       模型看到提醒 → 调 todo_write 提交快照
为什么不是第 3 次看到提醒「计数到 3」和「注入提醒」不在同一次请求:第 3 次请求时计数刚好到 3,注入发生在第 4 次请求前。
5 运行第 5 章
0
准备环境

Set-Location code + npm ci

1
先跑离线测试

npm run test:ch05 预期 14 个文件 109 个测试。

2
让模型先规划再执行

prompt 写明「先建立完整 TODO」。

3
观察快照内容

看模型复述的完整 JSON(中文是 \uXXXX)。

4
统一入口

agent-tutorial -- run --chapter 5

离线测试 / 运行命令
npm run test:ch05   # 预期 14 files / 109 tests

npm run ch05 -- --prompt "先建立完整 TODO,再读取 README.md 并总结运行和验证步骤"
# stderr 有 [Hook]/[Permission];模型是否先调 todo_write 取决于模型本身,
# 系统提示是引导,不是强制门控。不调就把 prompt 写更明确。

npm run ch05 -- --prompt "先用 todo_write 建立三项计划,然后原样告诉我 todo_write 返回的完整文本"
# 模型复述出的是一整份 {"todos":[...]},中文是 \uXXXX 形式
常见报错排查
现象原因 / 处理
模型压根不调用 todo_write系统提示只是引导。把 prompt 写明「先建立完整 TODO」;本章不强制门控
模型没按提醒刷新提醒只在当次请求出现,不进历史;靠模型调用 todo_write 才算「听进去了」
invalid_argumentsZod 在 prepare 拦下;旧快照没变,handler 没跑
改了 STALE_TOOL_ROUNDS 后测试红todos.test.ts 断言默认 3;改完记得改回
6 验证与实验

npm run test:ch05 执行 14 个测试文件、109 个测试。四个值得亲手做的实验:

实验预期学到什么
一 · 撞一次 invalid_arguments
提交非法 todo,断言旧快照没变
toBe 而非 toEqual——失败路径连新对象都没创建Zod 在 prepare 拦下,#writeTodos 没跑
二 · 改已保存的快照
严格模式改冻结对象
抛 TypeError,快照值不变(需 @ts-expect-error)readonly 只管编译期,Object.freeze 管运行时
三 · STALE_TOOL_ROUNDS 改成 1
让模型连续读几个文件
几乎每次请求都带提醒阈值不能太小——提醒本身占注意力,变成新噪音。记得改回 3
四 · 一轮多个调用只算一轮第一个 beforeModel 不提醒按 assistant 消息计数,不按调用个数
7 本章小结
三句话版本
  • 长任务里丢的不是「目标」,是「进度」——目标在开头出现过一次,进度从来没被显式记录。
  • 所以本章把进度做成结构化状态:模型每次提交完整快照,快照作为最新的 tool result 回到消息流。
  • 模型忘记更新时,运行时按「连续三个陈旧工具轮」注入一条只进当次请求的提醒。
一定要记住的七条
#结论在哪一节
1整体替换没有隐式 merge:漏写一项 = 删除一项
2校验全部通过后才做一次赋值,不存在半更新,也不需要回滚二、九
3readonly 只管编译期,运行时要靠 Object.freeze
4快照和陈旧计数都是会话状态,做成单例会串台
5按 assistant 消息计数,一轮三个调用只算一轮
6「计数到 3」和「注入提醒」不在同一次请求;注入后立刻清零
7提醒进 ModelRequest,不进 #history
本章代码边界(明确「还没做什么」)
已经有还没有说明
会话内 TODO 快照磁盘持久化、跨会话恢复进程退出即消失。第 9 章做文件记忆
单一整体替换工具依赖关系、blockedBy、并发认领第 12、17 章做任务 DAG
固定「三轮」阈值官方那种「完成 3 项却没 verification 就提醒」刻意选固定轮数,让离线测试可确定性断言
一条固定文案的提醒按任务内容动态生成不做。动态文案无法精确断言
提醒只注入当次请求提醒进入正式历史不做。会在轨迹里累积过期提示
effect: write 参与决策用 effect 粗暴匹配审批规则规则精确匹配工具名
单一会话的 tracker全局共享的 tracker不做。见第四节串台示例
检查你是否真的读懂了(不看文章回答)
  • 模型提交 3 项计划,下一次只提交 1 项,另外 2 项会怎样?
  • 为什么快照要输出 编写 而不是直接输出中文?
  • TodoTracker 做成单例 export const 会出什么问题?至少说两个。
  • 一条 assistant 消息里有 3 个工具调用,计数加几?
  • 提醒在第几次模型请求出现?为什么不是第 3 次?
  • 在 result.history 里能搜到 Keep the TODO list current 吗?为什么?
  • todo_write 的 effect 是 write,为什么不弹审批框?审计里有东西吗?
8 QA 测试环节(自测题)
已完成 0 / 7 · 答对 0
Q1. 长任务里最容易丢的是什么?
Q2. todo_write 采用什么更新语义?
Q3. 为什么提交非法 TODO 不会破坏旧快照?
Q4. TodoTracker 做成模块级单例会怎样?
Q5. Nag 提醒按什么计数?
Q6. 提醒在「计数到 3」之后的哪一次请求出现?
Q7. 提醒进历史会有什么问题?