上下文与注意力
理解长任务中的“失忆”:不是简单的越靠后越重要,而是信息竞争与重复噪声累积。
先看它怎样跑起来
这一章不是几个孤立知识点,而是一条会产生结果的因果链。
上下文与注意力
亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。
- 长上下文会让早期目标与局部日志竞争注意力。
- todo_write 不替模型做计划,只保存完整计划快照。
- 连续多轮没有更新时,运行时可以提醒一次。
顺着原文把边界看清
01导读:问题背景与本章目标它先读文件、改类型、跑测试;第三步遇到失败后,注意力全被报错占住。测试最后修好了,但最初要求处理的另一批文件没有动,验收步骤也忘了执行。⌄
给 Agent 一个十步重构任务。
它先读文件、改类型、跑测试;第三步遇到失败后,注意力全被报错占住。测试最后修好了,但最初要求处理的另一批文件没有动,验收步骤也忘了执行。
任务还在对话开头,并没有真的消失。问题是:随着工具结果、文件内容和错误日志持续进入上下文,最初计划只出现一次,却要和大量局部信息竞争模型的注意力。
本章给 Agent 增加一个很小但很关键的能力:会话级 todo_write。它不替模型完成任务,只让模型反复提交“当前完整计划快照”,并在连续三轮工具调用都没有更新计划时提醒一次。
02先看一眼真实的一次运行一次成功的快照提交 + 「提醒到底在第几次请求出现」。⌄
一次成功的快照提交。模型调用 todo_write 后,工具把整份 TODO 以 tool result 回填进消息历史——从这一刻起,模型有了「最新进度」的显式记忆。
提醒到底在第几次请求出现:「计数到 3」和「注入提醒」不在同一次请求。
请求1 模型连续调用工具(读文件) 陈旧计数 0→1
请求2 模型又调工具,没提交快照 陈旧计数 1→2
请求3 模型又调工具,没提交快照 陈旧计数 2→3
请求4 beforeModel: 计数已到 3 → 注入提醒,清零 ← 第 4 次请求看到提醒
模型看到提醒 → 调 todo_write 提交快照为什么不是第 3 次看到提醒?第 3 次请求时计数刚好到 3,注入发生在第 4 次请求前——「计数到 3」和「注入提醒」分属两次请求。
03先明确:长上下文不是“越靠后权重越高”Transformer 会根据内容动态计算注意力,不能简单理解为“后面的 token 一定比前面的重要”。但长任务中会出现三个很现实的问题:⌄
Transformer 会根据内容动态计算注意力,不能简单理解为“后面的 token 一定比前面的重要”。但长任务中会出现三个很现实的问题:
- 工具输出不断增长,早期目标要与更多内容竞争注意力;
- 相似日志和局部错误会反复出现,模型更容易围绕眼前问题继续生成;
- 最初计划只描述“要做什么”,却没有持续提供“已经做到哪一步”。
因此,系统提示里写一句“不要忘记计划”并不够。它只能表达原则,不能保存任务状态。
本章的思路是把计划变成结构化状态,再通过工具结果把最新快照放回消息流。这样模型下一轮看到的不只是最初目标,还有最近一次明确提交的进度。
这里仍然要把能力边界说清楚:TODO 能降低偏航概率,不能证明任务已经完成。最终结果仍要由测试、静态检查或其他可观察验收来确认。
04本章要得到什么第 5 章建立在前四章之上,保留文件工具、权限策略和四类 Hook,只增加 TODO capability。⌄
第 5 章建立在前四章之上,保留文件工具、权限策略和四类 Hook,只增加 TODO capability。
可观察的验收结果有四个:
todo_write每次返回整个 TODO 列表的稳定 JSON 快照,而不是只返回本次变化;- 非法更新不会覆盖旧快照;
- 每个 Agent 会话拥有独立状态;
- 连续三轮调用其他工具后,下一次模型请求只注入一条临时提醒。
失败边界同样明确:todos 不是数组、内容为空、状态未知、超过 50 项、带未知字段或 arguments 不是合法 JSON,handler 都不能改变状态。
整体数据流如下:
assistant tool_calls
│
▼
ToolRegistry 解析 JSON + Zod 校验
│
├── 失败 ──> 配对的 tool error,旧快照不变
│
▼
权限 / Hook / handler
│
▼
TodoTracker 原子替换完整快照
│
▼
稳定 JSON 作为 tool result 进入正式历史提醒走另一条路径:
完成一个工具轮
│
├── 本轮有 todo_write ──> 计数归零
│
└── 本轮没有 todo_write ──> 计数加一
│
└── 达到 3:下一次模型请求临时注入 system reminder05一、用 Zod 定义唯一输入契约实现位于 code/chapters/ch05/src/features/todos.ts。先定义允许的状态和数量上限:⌄
实现位于 code/chapters/ch05/src/features/todos.ts。先定义允许的状态和数量上限:
export const MAX_TODOS = 50;
export const STALE_TOOL_ROUNDS = 3;
export const TODO_STATUSES = Object.freeze([
"pending",
"in_progress",
"completed",
] as const);
export type TodoStatus = (typeof TODO_STATUSES)[number];as const 保留三个字符串字面量,TodoStatus 因而是:
type TodoStatus = "pending" | "in_progress" | "completed";接着用 strict Zod schema 描述工具参数:
const todoItemSchema = z
.object({
content: z
.string()
.transform((content) => content.trim())
.pipe(z.string().min(1, "todo content must not be empty")),
status: z.enum(TODO_STATUSES),
})
.strict();
const todoWriteSchema = z
.object({
todos: z.array(todoItemSchema).max(MAX_TODOS),
})
.strict();
export type TodoItem = Readonly<z.output<typeof todoItemSchema>>;
type TodoWriteInput = z.output<typeof todoWriteSchema>;这段 schema 同时完成几件事:
content必须是字符串;- 先执行
trim(),再检查结果不能为空; status只能是三种已知值;- 每次最多提交 50 项;
- 两层
.strict()都拒绝未知字段。
为什么先 transform 再 pipe?如果直接对原字符串做 min(1)," " 会通过长度检查。现在空白被去掉后再验证,它会稳定失败。
更重要的是,ToolDefinition 直接持有这个 schema。ToolRegistry 既用它生成发给 OpenAI 的 JSON Schema,也用它在 dispatch 前验证输入,不需要再维护一套手写参数类型。模型看到的工具契约和 handler 实际接收的契约来自同一个源头。
非法 JSON 更早失败:Registry 先解析 tool_call.arguments。解析失败返回 invalid_json;能解析但不符合 Zod schema,则返回 invalid_arguments。这两条路径都不会调用 handler。
ToolRegistry.openAITools() 同样复用这份 schema:模型看到的 parameters 由 z.toJSONSchema(definition.inputSchema) 生成,运行时再传给 prepare() 做 safeParse。
开发时只维护一套类型契约。模型声明、权限策略和 handler 收到的参数不会因为手工同步而漂移。
06二、一次提交必须是完整快照本章没有设计 todoadd、todoupdate、todoremove 三组增量接口,只提供一个整体替换工具:⌄
本章没有设计 todo_add、todo_update、todo_remove 三组增量接口,只提供一个整体替换工具:
export class TodoTracker {
#todos: readonly TodoItem[] = Object.freeze([]);
#nonTodoToolRounds = 0;
readonly toolDefinition: ToolDefinition<TodoWriteInput>;
constructor() {
this.toolDefinition = Object.freeze({
name: "todo_write",
description: "Replace the current TODO list with a complete task snapshot.",
inputSchema: todoWriteSchema,
effect: "write",
handler: (input: TodoWriteInput) => this.#writeTodos(input),
});
}
get todos(): readonly TodoItem[] {
return this.#todos;
}
#writeTodos(input: TodoWriteInput): ToolResult {
// 先由 Zod 完整校验,再一次替换快照;失败路径不会触碰旧状态。
this.#todos = Object.freeze(
input.todos.map((item) =>
Object.freeze({ content: item.content, status: item.status }),
),
);
this.#nonTodoToolRounds = 0;
return toolSuccess(serializeSnapshot(this.#todos));
}
}完整快照比增量命令更适合模型调用,原因很实际:
- 当前状态只由最后一次成功调用决定,容易解释和重放;
- 不需要处理“更新一个已经不存在的索引”之类的额外状态机;
- tool result 本身就包含后续推理需要的全部计划;
- 校验完成后只执行一次赋值,失败不会产生半更新状态。
例如第一次提交:
{
"todos": [
{ "content": "读取现有实现", "status": "in_progress" },
{ "content": "增加失败测试", "status": "pending" },
{ "content": "实现并验证修复", "status": "pending" }
]
}第一步完成后,模型不能只提交“第一项已完成”,而要再次提交三项完整状态:
{
"todos": [
{ "content": "读取现有实现", "status": "completed" },
{ "content": "增加失败测试", "status": "in_progress" },
{ "content": "实现并验证修复", "status": "pending" }
]
}这就是工具描述里 complete task snapshot 的含义。
07为什么还要冻结对象TypeScript 的 readonly 只在编译期约束调用方,运行时数组仍可能被修改。因此 tracker 在保存时重新构造每一项,并冻结 item 和外层数组。⌄
TypeScript 的 readonly 只在编译期约束调用方,运行时数组仍可能被修改。因此 tracker 在保存时重新构造每一项,并冻结 item 和外层数组。
这不是为了制造复杂抽象,而是为了守住一个直接契约:成功更新后的快照只能由下一次合法 todo_write 替换,不能被持有引用的其他代码偷偷改掉。
08三、返回稳定且完整的 JSONhandler 不返回“updated 3 todos”,而是返回完整快照:⌄
handler 不返回“updated 3 todos”,而是返回完整快照:
{"todos":[{"content":"ship","status":"completed"}]}实现先按固定字段顺序构造数据,再序列化:
function serializeSnapshot(todos: readonly TodoItem[]): string {
const json = JSON.stringify({
todos: todos.map((item) => ({
content: item.content,
status: item.status,
})),
});
const ascii: string[] = [];
for (const character of json) {
const codePoint = character.codePointAt(0);
if (codePoint === undefined) {
throw new Error("todo snapshot contained an invalid Unicode value");
}
if (codePoint <= 0x7f) {
ascii.push(character);
continue;
}
if (codePoint <= 0xffff) {
ascii.push(`\\u${codePoint.toString(16).padStart(4, "0")}`);
continue;
}
const offset = codePoint - 0x10000;
const high = 0xd800 + (offset >> 10);
const low = 0xdc00 + (offset & 0x3ff);
ascii.push(`\\u${high.toString(16)}\\u${low.toString(16)}`);
}
return ascii.join("");
}这里保留 ASCII JSON,是为了让同一个逻辑快照始终产生确定文本。中文会表示成 JSON Unicode escape,例如:
编写测试对应:
\u7f16\u5199\u6d4b\u8bd5它们解码后的内容相同。稳定文本让测试可以精确断言,也避免日志、平台编码和迁移前后序列化差异干扰比较。
09四、状态必须属于会话,而不是模块TodoTracker 不能做成模块级单例。否则同一个 Node.js 进程里创建两个 Agent 时,第二个会话会看到第一个会话的计划。⌄
TodoTracker 不能做成模块级单例。否则同一个 Node.js 进程里创建两个 Agent 时,第二个会话会看到第一个会话的计划。
组合根在每次 buildAgent() 时创建 tracker:
const todoTracker = profile.capabilities.has("todo")
? new TodoTracker()
: undefined;
if (todoTracker !== undefined) {
tools.register(todoTracker.toolDefinition);
}随后,同一个实例既提供工具 handler,也作为工具轮观察者传给 Loop:
return new AgentRunner({
model: dependencies.model,
tools,
systemPrompt:
todoTracker === undefined
? SYSTEM_PROMPT
: `${SYSTEM_PROMPT}${TODO_SYSTEM_PROMPT}`,
workspace: dependencies.workspace,
...(todoTracker === undefined
? {}
: { toolRoundObserver: todoTracker }),
});这样得到的生命周期很清楚:
- 同一次
AgentRunner运行中,工具和 reminder 共用一个 tracker; - 两次
buildAgent()得到两个 tracker,状态互不影响; - 进程退出后状态消失,本章不做磁盘持久化。
第 12 章的项目 Task 会解决跨会话持久化、依赖和认领问题。它与本章的会话 TODO 生命周期不同,不应该提前揉进一个全局状态对象。
10五、系统提示只负责告诉模型“什么时候用”"\nFor complex tasks, call todowrite with the complete task snapshot and update it when the plan changes.";⌄
第 5 章在基础系统提示后追加一行:
const TODO_SYSTEM_PROMPT =
"\nFor complex tasks, call todo_write with the complete task snapshot and update it when the plan changes.";工具 description 说明“工具做什么”,系统提示说明“复杂任务何时使用”。两者都不会强制模型调用工具,因此不能把“真实运行第一次一定调用 todo_write”当作确定性保证。
真正可以离线验证的是:
- P05 的工具列表包含且只新增
todo_write; - 系统提示包含完整快照要求;
- 模型一旦正确调用,运行时能校验、保存并返回快照;
- 模型忘记更新时,运行时会按规则注入提醒。
11六、Nag Reminder 按工具轮计数一个 assistant 消息可以同时包含多个 tool call。如果同一轮读了三个文件,提醒计数应该增加一次,而不是三次。⌄
一个 assistant 消息可以同时包含多个 tool call。如果同一轮读了三个文件,提醒计数应该增加一次,而不是三次。
TodoTracker.recordToolRound() 接收这一轮的全部工具名:
recordToolRound(toolNames: readonly string[]): void {
if (toolNames.length === 0) {
return;
}
if (toolNames.includes(this.toolDefinition.name)) {
this.#nonTodoToolRounds = 0;
return;
}
this.#nonTodoToolRounds += 1;
}规则只有三条:
- 没有工具调用,不计数;
- 本轮只要出现
todo_write,计数归零; - 本轮有工具但没有
todo_write,计数加一。
达到阈值后,beforeModel() 返回一条 system guidance,并立刻清零:
export const TODO_STALE_REMINDER =
"Keep the TODO list current. Call todo_write with the complete task snapshot when the plan changes.";
beforeModel(): readonly ChatMessage[] {
if (this.#nonTodoToolRounds < STALE_TOOL_ROUNDS) {
return [];
}
// 提醒是请求级上下文;立即清零可避免后续每轮重复注入。
this.#nonTodoToolRounds = 0;
return Object.freeze([systemMessage(TODO_STALE_REMINDER)]);
}注意“达到三轮”和“注入提醒”不是同一个模型请求。第三个陈旧工具轮执行完后计数才变成 3,因此 reminder 出现在下一次,也就是第四次模型请求中:
| 模型请求 | 上一轮工具 | 请求中有 reminder | 轮后计数 |
|---|---|---|---|
| ---- | ----------- | ------------: | ------: |
| 1 | 无 | 否 | 0 |
| 2 | read_file | 否 | 1 |
| 3 | glob | 否 | 2 |
| 4 | shell | 是 | 重置后重新计数 |
如果第四次请求又产生一个非 TODO 工具轮,轮后计数从 0 变为 1,不会在第五次请求重复提醒。
12七、提醒只进入当次请求,不污染正式历史this.#toolRoundObserver === undefined⌄
Loop 在调用模型前读取 guidance:
const observerGuidance =
this.#toolRoundObserver === undefined
? []
: this.#toolRoundObserver.beforeModel();
const request: ModelRequest = Object.freeze({
messages: Object.freeze([
systemMessage(this.#systemPrompt),
...this.#history,
...observerGuidance,
]),
tools: tools.openAITools(),
});关键是 observerGuidance 只用于构造当前 ModelRequest,没有 push 进 #history。
这样做有两个直接好处:
- 提醒靠近当前生成位置,能发挥提示作用;
- 它不会永久堆积,后续请求也不会看到一串过期 reminder。
一轮中的所有工具都完成、所有 tool result 都配对写入历史后,Loop 再记录工具名:
if (this.#toolRoundObserver !== undefined) {
this.#toolRoundObserver.recordToolRound(
assistant.toolCalls.map((call) => call.name),
);
}这个位置不能随便移动。先完成 tool result 配对,才能保证观察逻辑不会打断 OpenAI 的消息协议;按整条 assistant 消息记录,才能保证多个调用只算一轮。
13八、write 副作用不等于磁盘写审批todowrite 的 effect 是 "write",因为它确实改变会话内状态:⌄
todo_write 的 effect 是 "write",因为它确实改变会话内状态:
effect: "write"但第 3 章的文件审批规则精确匹配 write_file 和 edit_file,而不是粗暴拦截所有 write effect:
matches: (request) => {
const name = request.prepared.definition?.name;
return name === "write_file" || name === "edit_file";
}因此更新 TODO 不会弹出磁盘写入审批;它仍然经过统一权限决策并写入审计。这个区分很重要:副作用类别描述行为性质,具体策略决定哪些行为需要人工确认。
第 5 章仍保留已有边界:
write_file、edit_file继续要求明确批准;shell继续按已有策略处理;- workspace 外写入不能被 Hook 或审批放宽;
- Hook 的 deny、输出改写和 Stop 语义保持不变。
14九、失败为什么不会破坏旧快照{ "content": "保留这项", "status": "inprogress" }⌄
假设当前状态已经是:
{
"todos": [
{ "content": "保留这项", "status": "in_progress" }
]
}模型随后提交非法状态:
{
"todos": [
{ "content": "错误更新", "status": "working" }
]
}调用路径是:
JSON.parse
-> todoWriteSchema.safeParse
-> 失败,生成 invalid_arguments
-> handler 未调用
-> #todos 引用和值都保持不变这比“进入 handler 后逐项检查、逐项写入”简单得多,也没有回滚问题。状态赋值只有一个入口,并且只接收经过 Zod 验证的 TodoWriteInput。
测试不仅比较内容,还保存更新前的数组引用,确认失败后仍是同一个快照对象。这样可以证明失败路径没有偷偷创建或替换状态。
类似的信任边界也出现在 handler 输出侧。ToolRegistry.invoke() 把 handler 的返回值视为不可信输入,只有通过 isToolResult() 检查的对象才能进入正式历史;返回畸形对象时,Loop 会把它替换成 invalid_tool_result 错误,避免任意结构污染消息协议。
15十、离线测试如何证明这些契约单元测试位于 code/chapters/ch05/tests/todos.test.ts,覆盖:⌄
单元测试位于 code/chapters/ch05/tests/todos.test.ts,覆盖:
- 三种状态和
content.trim(); - 完整、稳定、ASCII JSON 快照;
- 恰好 50 项的上边界;
- 非数组、空内容、未知状态、未知字段、51 项和非法 JSON;
- 失败前后快照对象和值都不变;
- 两个 tracker 的会话隔离;
- 三轮提醒只出现一次;
todo_write重置陈旧轮计数。
集成测试位于 code/chapters/ch05/tests/ch05-todos.test.ts,从 buildAgent(P05, dependencies) 进入真实组合根,验证:
- 工具集合是前四章工具加
todo_write; - TODO 系统提示已进入模型请求;
todo_write不请求文件写审批,但产生默认 allow 审计;- tool result 与原
tool_call_id正确配对; - reminder 只出现在第四次模型请求,不进入正式 history;
- 后续请求不会无条件重复 reminder。
运行第五章最小验证:
Set-Location 'F:\笔记\Agent实操\code'
npm run typecheck
npm run test:ch05因为本章修改了公共 Loop,完成 review 前还要跑全量回归和静态检查:
Set-Location 'F:\笔记\Agent实操\code'
npm test
npm run lint
npm run format:check
npm run build测试使用 ScriptedModelClient 和临时 workspace,不读取 .env,也不访问网络。它验证的是运行时的确定性契约,而不是依赖模型随机表现做演示。
16十一、运行第 5 章Set-Location 'F:\笔记\Agent实操\code'⌄
章节入口:
Set-Location 'F:\笔记\Agent实操\code'
npm run ch05 -- --prompt "先建立完整 TODO,再读取 README.md 并总结运行和验证步骤"统一 CLI 入口:
Set-Location 'F:\笔记\Agent实操\code'
npm run agent-tutorial -- run --chapter 5 --prompt "先建立完整 TODO,再读取 README.md 并总结运行和验证步骤"两个入口最终都使用 P05 和同一个 buildAgent(),不会复制一套第五章 Loop。固定章节文件只有三行:
import { runProfile } from "../cli.js";
import { P05 } from "../core/profiles.js";
process.exitCode = await runProfile(P05, process.argv.slice(2));runProfile(P05, ...) 会把固定 profile 交给统一 CLI 路由:路由仍只解析 --prompt,并在内部强制填入 --chapter 5,因此即使命令参数里出现其他章节号,也不会意外切换能力。
如果三个 OpenAI 配置字段缺失,CLI 会在网络请求前明确列出缺失项。真实模型是否主动维护 TODO 属于额外观察;会话隔离、失败不改状态、提醒时机和消息配对已经由离线测试直接证明。
17十二、观察真实运行时应该看什么第一,看模型是否在复杂任务开始时提交完整 TODO。系统提示是引导,不是强制门控;如果模型直接执行,先检查 prompt 是否真的要求规划,再评估模型行为。⌄
第一,看模型是否在复杂任务开始时提交完整 TODO。系统提示是引导,不是强制门控;如果模型直接执行,先检查 prompt 是否真的要求规划,再评估模型行为。
第二,看状态是否形成合理流转:
pending -> in_progress -> completed一次把所有项目从 pending 改成 completed 虽然合法,却说明中间进度没有被记录。任务若在过程中中断,最后一份快照可能已经过时。
第三,看每次更新是否仍包含全部任务。完整替换接口没有隐式 merge;遗漏一项,就表示模型明确从当前计划中删除了它。
第四,看 reminder 的时机。它只在连续三轮其他工具之后出现一次。如果模型随后仍不更新,必须再经历三个新的陈旧工具轮才会再次提醒。
最后,不要把 TODO 的 completed 当作验收证据。它是模型提交的计划状态,不是测试结果。代码是否正确,仍以本章列出的直接验证为准。
18十三、与 Claude Code 的差异本章的 todowrite 是教学子集。Claude Code 内部并存两套任务系统(差异整理自参考笔记 shareAI-lab/learn-claude-code/s05todowrite/README.md):⌄
本章的 todo_write 是教学子集。Claude Code 内部并存两套任务系统(差异整理自参考笔记 shareAI-lab/learn-claude-code/s05_todo_write/README.md):
- TodoWrite(V1):一个
todo_write工具,列表保存在会话内存中,退出后清空; - Task System(V2):文件持久化、
blockedBy依赖图、并发锁、任务所有权,以及 Create/Get/Update/List 四个独立工具。
P05 的固定“连续三轮未更新就提醒”是教学机制。Claude Code 没有同样的固定轮数,更接近的行为是在已完成 3 项以上 TODO 却没有 verification 项时追加验证提醒;第 5 章刻意选择固定轮数,是为了让离线测试能确定性地证明“提醒只出现一次、不污染正式历史”。
Anthropic 在《Building Effective Agents》中把“显式展示 Agent 的规划步骤”列为实现透明性的核心原则(https://www.anthropic.com/engineering/building-effective-agents)。
P05 的完整快照工具结果就是这个原则的可测试落点:模型每次提交的 TODO 都进入正式消息历史,读者和运行时都能看到它当前认为的完整计划。
19十四、与《ai-agent-book》的对照:注意力检索、状态栏与轨迹边界本章的实现与《ai-agent-book》第二章、第三章和第五章的几个关键概念直接呼应。把它们放在一起看,更容易理解 todowrite 为什么值得这样设计。⌄
本章的实现与《ai-agent-book》第二章、第三章和第五章的几个关键概念直接呼应。把它们放在一起看,更容易理解 todo_write 为什么值得这样设计。
状态栏:把算好的结论直接加进上下文。《ai-agent-book》第二章介绍 Agent 状态栏时提出:不要让模型在海量原始信息中被动检索,而是把经过提炼的结构化状态主动放到模型面前。P05 的完整 TODO 快照正是这个原则的可测试落点。
每次 todo_write 返回的不是“已更新 3 项”,而是“截至此刻,完整计划是什么”;模型下一轮不需要从头翻阅系统提示或早期对话,只需要查看最近一次工具结果。
注意力擅长检索,不擅长统计与推理。《ai-agent-book》第二章还指出,Transformer 的注意力机制本质上像软检索:它擅长在已有内容中定位与当前查询相关的片段,却不擅长在大量 token 之间做统计归纳。
这解释了系统提示词为什么会在长上下文中逐渐失效:初始计划只出现一次,随着工具输出增长,它要与越来越多的局部信息竞争注意力。TODO 快照把“当前进度”从需要模型自己推理的状态,变成了可以直接检索的显式 JSON。
轨迹保持 append-only,临时提醒不走历史。《ai-agent-book》第三章把轨迹定义为按时间顺序追加、只增不改的完整事件记录。P05 的 reminder 没有写入 #history,只在 beforeModel() 构造当次请求时临时加入,因此不会在轨迹中累积过期提醒。
这个设计和“完整快照作为最近一次工具结果”共同保证:正式历史始终是真实工具调用与结果的配对记录,临时提示不会污染审计和重放。
显式状态优于隐式期望。 把系统提示写成“不要忘记计划”,是给模型一条原则,但无法保存“当前进度”。P05 用结构化工具把状态变成可校验、可断言、可替换的数据契约:模型提交完整 JSON,运行时校验并替换,非法输入不改变旧状态。这正是《ai-agent-book》第二章“主动提供经过提炼的结构化知识”的工程化表达。
这段对照不是要说明 P05 已经实现书中全部能力,而是说明它选择了最基础、最可验证的一条路径:把容易丢失的计划状态从长上下文中抽出来,变成模型每次都能稳定看到的工具结果。
20本章完整改动- code/chapters/ch05/src/features/todos.ts:Zod 契约、会话快照和 reminder 计数;⌄
相较第 4 章,第 5 章只增加一条实现路径:
code/chapters/ch05/src/features/todos.ts:Zod 契约、会话快照和 reminder 计数;code/chapters/ch05/src/core/loop.ts:模型调用前读取 guidance,工具轮后记录工具名;code/chapters/ch05/src/bootstrap.ts:每个 P05 会话创建并注册一个 tracker;code/chapters/ch05/src/core/profiles.ts:新增固定P05和todocapability;code/chapters/ch05/src/chapters/ch05.ts:第五章固定入口;code/chapters/ch05/tests/todos.test.ts:TODO 领域行为和失败边界;code/chapters/ch05/tests/ch05-todos.test.ts:从组合根验证权限、消息和提醒接缝。
没有增加数据库、全局状态、增量更新协议或第二套 Loop。这个实现只解决当前问题:让单次 Agent 会话拥有一个可验证、可重复提交、不会被非法输入破坏的任务快照。
下一章会处理更大的任务:把一个目标交给独立子 Agent,在隔离历史中执行,再只把最终结果带回父 Agent。那解决的是上下文隔离,不是本章的会话内计划追踪。
21本章小结三句话版本、一定要记住的七条、以及本章「还没做什么」。⌄
三句话版本:
- 长任务里丢的不是「目标」,是「进度」——目标在开头出现过一次,进度从来没被显式记录。
- 所以本章把进度做成结构化状态:模型每次提交完整快照,快照作为最新的 tool result 回到消息流。
- 模型忘记更新时,运行时按「连续三个陈旧工具轮」注入一条只进当次请求的提醒。
一定要记住的七条:
| # | 结论 | 在哪一节 |
|---|---|---|
| 1 | 整体替换没有隐式 merge:漏写一项 = 删除一项 | 二 |
| 2 | 校验全部通过后才做一次赋值,不存在半更新,也不需要回滚 | 二、九 |
| 3 | readonly 只管编译期,运行时要靠 Object.freeze | 二 |
| 4 | 快照和陈旧计数都是会话状态,做成单例会串台 | 四 |
| 5 | 按 assistant 消息计数,一轮三个调用只算一轮 | 六 |
| 6 | 「计数到 3」和「注入提醒」不在同一次请求;注入后立刻清零 | 六 |
| 7 | 提醒进 ModelRequest,不进 #history | 七 |
本章代码边界(明确「还没做什么」):会话内 TODO 快照不做磁盘持久化(第 9 章文件记忆);不做依赖/blockedBy(第 12、17 章任务 DAG);固定「三轮」阈值而非按内容动态提醒;一条固定文案而非动态生成;提醒只注入当次请求,不进正式历史;不做全局共享 tracker。
检查你是否真的读懂了:
- 模型提交 3 项计划,下一次只提交 1 项,另外 2 项会怎样?
- 为什么快照要输出 编写 而不是直接输出中文?
- TodoTracker 做成单例会出什么问题?至少说两个。
- 一条 assistant 消息里有 3 个工具调用,计数加几?
- 提醒在第几次模型请求出现?为什么不是第 3 次?
- 在 result.history 里能搜到 Keep the TODO list current 吗?为什么?
- todo_write 的 effect 是 write,为什么不弹审批框?审计里有东西吗?
换个场景,你还会判断吗?
每题只测一个边界。先做决定,再看解释。
准备开始