为什么上下文越长,系统提示词越没用?
todo_write:让模型反复提交完整计划快照,连续三轮没更新就提醒一次。🎯 本章导读
- 解释为什么长任务里丢的不是「目标」而是「进度」,以及为什么进度必须被显式记成结构化状态。
- 用 Zod 定义一份唯一输入契约,写一个「整体替换」的 TODO 快照工具。
- 说出「一口快照」为什么不需要回滚:校验全过才赋值,失败连新对象都不创建。
- 说明为什么提醒必须只进当次请求、不污染正式历史,以及按什么计数。
- 跑通第 5 章:让 Agent 先建立 TODO 再执行,观察提醒在第几次请求出现。
你需要先具备什么 ▸
| 需要 | 说明 |
|---|---|
| 读完第 1—4 章 | 会看 Agent Loop、Zod、prepare/permission/invoke、Hook 即可 |
| TypeScript | 能看懂 interface、readonly、Object.freeze |
| Transformer 常识 | 知道「注意力检索」大致是什么。这一章不做数学推导 |
建议的阅读路线 ▸
本章导读(你在这)
↓
① 先看一眼真实的一次运行 ← 一次任务两条关键日志
↓
② 先明确:长上下文不是靠后权重大
↓
③ Zod 契约 + 完整快照 ← 一、二
↓
④ 稳定 JSON + 会话状态 ← 三、四
↓
⑤ 系统提示只管"什么时候用" + Nag 计数 ← 五、六
↓
⑥ 当次注入 + 副作用标签 ← 七、八
↓
⑦ 失败不破坏旧快照 + 离线证明 ← 九、十
↓
⑧ 运行 + 实验 + 差异 ← 十一~十四
↓
⑨ 小结 ← 三句话 + 七条 + 边界 + 自测
📇 术语速查
| 术语 | 一句话解释 |
|---|---|
| 完整快照 | 模型每次提交的整份 TODO 状态,整体替换上一份,不是增量 diff |
| 静态系统提示 | system 里一份固定文案;只告诉模型「什么时候该更新」,不携带进度数据 |
| Nag Reminder | 按工具轮计数注入的「提醒你刷新 TODO」提示。只在当次请求出现 |
| 漏写即删除 | 整体替换没有隐式 merge:漏写一项 = 删除一项 |
\uXXXX 转义 | 返回的 JSON 里中文被转义(如 编写 → \u7f16\u5199),保证字节级稳定可断言 |
| 会话状态 | todo 快照和陈旧计数都属于「会话」,做成单例会串台 |
| 陈旧工具轮 | 连续 N 次有工具调用但模型没有提交快照的轮次 |
| STALE_TOOL_ROUNDS | 陈旧阈值,默认 3;「计数到 3」和「注入提醒」不在同一次请求 |
📡 先看一眼真实的一次运行
一次成功的快照提交 ▸
模型调用 todo_write 后,工具把整份 TODO 以 tool result 回填,消息历史里从此就有了一份「最新进度」——这就是模型的显式记忆。
提醒到底在第几次请求出现 ▸
请求1 模型连续调用工具(读文件) 陈旧计数 0→1
请求2 模型又调工具,没提交快照 陈旧计数 1→2
请求3 模型又调工具,没提交快照 陈旧计数 2→3
请求4 beforeModel: 计数已到 3 → 注入提醒,清零 ← 第 4 次请求看到提醒
模型看到提醒 → 调 todo_write 提交快照
🧠 核心概念
1 · 长上下文不是「越靠后权重越高」 ▸
Transformer 根据内容动态计算注意力,不能简单理解为「后面 token 一定比前面重要」。但长任务有三个现实问题:
- 工具输出不断增长,早期目标要与更多内容竞争注意力;
- 相似日志和局部错误反复出现,模型更围绕眼前问题继续生成;
- 最初计划只描述「要做什么」,却没持续提供「做到哪一步」。
系统提示写「不要忘记计划」只能表达原则,不能保存任务状态。本章思路:把计划变成结构化状态,通过工具结果把最新快照放回消息流。
2 · 完整快照,不是增量命令 ▸
没有 todo_add/update/remove 三组增量接口,只有一个整体替换工具。完整快照更适合模型:
- 当前状态只由最后一次成功调用决定,易解释和重放;
- 不需要处理「更新一个已不存在的索引」的状态机;
- tool result 本身包含后续推理需要的全部计划;
- 校验完成后只一次赋值,失败不产生半更新状态。
第一步完成后,模型不能只提交「第一项已完成」,要再次提交三项完整状态。
3 · Zod:先 trim 再 min(1) ▸
content: z.string()
.transform(c => c.trim())
.pipe(z.string().min(1, "todo content must not be empty")),
status: z.enum(["pending","in_progress","completed"]),
如果直接对原字符串 min(1)," " 会通过长度检查。先 trim 再验证,它会稳定失败。两层 .strict() 都拒绝未知字段。最多 50 项。
4 · 状态属于会话,不是模块 ▸
TodoTracker 不能做成模块级单例,否则同进程创建两个 Agent 时,第二个会话会看到第一个的计划。组合根在每次 buildAgent() 时 new 一个 tracker,同一个实例既提供工具 handler,也作为工具轮观察者传给 Loop。
进程退出后状态消失,本章不做磁盘持久化(第 12 章项目 Task 才解决跨会话持久化)。
5 · Nag Reminder 按工具轮计数 ▸
一个 assistant 消息可含多个 tool call。同一轮读三个文件,提醒计数应增加一次不是三次。规则三条:
- 没有工具调用,不计数;
- 本轮只要出现 todo_write,计数归零;
- 本轮有工具但没 todo_write,计数加一。
达到 3 后,beforeModel() 返回一条 system guidance 并立即清零。注意「达到三轮」和「注入提醒」不是同一个请求:
| 模型请求 | 上一轮工具 | 有 reminder | 轮后计数 |
|---|---|---|---|
| 1 | 无 | 否 | 0 |
| 2 | read_file | 否 | 1 |
| 3 | glob | 否 | 2 |
| 4 | shell | 是 | 重置后重新计数 |
6 · reminder 只进当次请求,不污染历史 ▸
Loop 调用模型前读取 guidance,只用于构造当前 ModelRequest,没有 push 进 history:
messages: [systemMessage(prompt), ...history, ...observerGuidance]
好处:提醒靠近当前生成位置能发挥提示作用;不会永久堆积,后续请求不会看到一串过期 reminder。
recordToolRound 在所有 tool result 配对写入历史后才记录工具名——位置不能乱移,否则会打断 OpenAI 消息协议。
7 · 失败不破坏旧快照 ▸
模型提交非法状态(status:"working"),路径是:JSON.parse → schema.safeParse 失败 → invalid_arguments → handler 未调用 → #todos 引用和值都不变。
状态赋值只有一个入口,只接收经 Zod 验证的输入,没有回滚问题。测试还保存更新前的数组引用,确认失败后仍是同一个快照对象。
8 · write effect ≠ 磁盘写审批 ▸
todo_write 的 effect 是 "write"(改变会话内状态),但第 3 章审批规则精确匹配 write_file 和 edit_file,不是粗暴拦截所有 write effect。所以更新 TODO 不弹磁盘写入审批,但仍过统一权限决策并写审计。
▶️ 交互演示:TODO 快照 + Nag 计数器
当前完整快照(每次提交都是整体替换)
🔑 一句话总结
显式状态优于隐式期望。系统提示是原则,不能保存「当前进度」;todo_write 把状态变成可校验、可断言、可替换的数据契约。
🚀 运行第 5 章
第 0–1 步 · 环境与离线测试 ▸
Set-Location 'F:\笔记\Agent实操\code'
npm ci
npm run test:ch05
Test Files 14 passed (14)
Tests 109 passed (109)
那行 配置错误: Missing required settings: ... 仍是被断言的预期输出(config.test.ts 验证缺配置时 CLI 返回退出码 2),不是失败。
第 2–4 步 · 先规划再执行、观察快照、统一入口 ▸
npm run ch05 -- --prompt "先建立完整 TODO,再读取 README.md 并总结运行和验证步骤"
# stderr 有 [Hook]/[Permission];模型是否先调 todo_write 取决于模型本身,
# 系统提示是引导,不是强制门控。不调就把 prompt 写更明确。
npm run ch05 -- --prompt "先用 todo_write 建立三项计划,然后原样告诉我 todo_write 返回的完整文本"
# 模型复述出整份 {"todos":[...]},中文是 \uXXXX 形式
npm run agent-tutorial -- run --chapter 5 --prompt "先建立完整 TODO,再读取 README.md 并总结运行和验证步骤"
常见报错排查表 ▸
| 现象 | 原因 / 处理 |
|---|---|
| 模型压根不调用 todo_write | 系统提示只是引导。把 prompt 写明「先建立完整 TODO」;本章不强制门控 |
| 模型没按提醒刷新 | 提醒只在当次请求出现,不进历史;靠模型调用 todo_write 才算「听进去了」 |
invalid_arguments | Zod 在 prepare 拦下;旧快照没变,handler 没跑 |
| 改了 STALE_TOOL_ROUNDS 后测试红 | todos.test.ts 断言默认 3;改完记得改回 |
🧪 验证与实验
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 消息计数,不按调用个数 |
📝 本章小结
- 长任务里丢的不是「目标」,是「进度」——目标在开头出现过一次,进度从来没被显式记录。
- 所以本章把进度做成结构化状态:模型每次提交完整快照,快照作为最新的 tool result 回到消息流。
- 模型忘记更新时,运行时按「连续三个陈旧工具轮」注入一条只进当次请求的提醒。
一定要记住的七条 ▸
| # | 结论 | 在哪一节 |
|---|---|---|
| 1 | 整体替换没有隐式 merge:漏写一项 = 删除一项 | 二 |
| 2 | 校验全部通过后才做一次赋值,不存在半更新,也不需要回滚 | 二、九 |
| 3 | readonly 只管编译期,运行时要靠 Object.freeze | 二 |
| 4 | 快照和陈旧计数都是会话状态,做成单例会串台 | 四 |
| 5 | 按 assistant 消息计数,一轮三个调用只算一轮 | 六 |
| 6 | 「计数到 3」和「注入提醒」不在同一次请求;注入后立刻清零 | 六 |
| 7 | 提醒进 ModelRequest,不进 #history | 七 |
本章代码边界(明确「还没做什么」) ▸
| 已经有 | 还没有 | 说明 |
|---|---|---|
| 会话内 TODO 快照 | 磁盘持久化、跨会话恢复 | 进程退出即消失。第 9 章做文件记忆 |
| 单一整体替换工具 | 依赖关系、blockedBy、并发认领 | 第 12、17 章做任务 DAG |
| 固定「三轮」阈值 | 官方那种「完成 3 项却没 verification 就提醒」 | 刻意选固定轮数,让离线测试可确定性断言 |
| 一条固定文案的提醒 | 按任务内容动态生成 | 不做。动态文案无法精确断言 |
| 提醒只注入当次请求 | 提醒进入正式历史 | 不做。会在轨迹里累积过期提示 |
| 单一会话的 tracker | 全局共享的 tracker | 不做。见第四节串台示例 |
检查你是否真的读懂了(不看文章回答) ▸
- 模型提交 3 项计划,下一次只提交 1 项,另外 2 项会怎样?
- 为什么快照要输出 编写 而不是直接输出中文?
- TodoTracker 做成单例 export const 会出什么问题?至少说两个。
- 一条 assistant 消息里有 3 个工具调用,计数加几?
- 提醒在第几次模型请求出现?为什么不是第 3 次?
- 在 result.history 里能搜到 Keep the TODO list current 吗?为什么?
- todo_write 的 effect 是 write,为什么不弹审批框?审计里有东西吗?