AGAgent 学习路线
第 5 / 20
CHAPTER 05 · GPT 生成学习页

上下文与注意力

理解长任务中的“失忆”:不是简单的越靠后越重要,而是信息竞争与重复噪声累积。

01 / 路线

先看它怎样跑起来

从输入到验收

这一章不是几个孤立知识点,而是一条会产生结果的因果链。

关键判断

上下文与注意力

亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。

  • 长上下文会让早期目标与局部日志竞争注意力。
  • todo_write 不替模型做计划,只保存完整计划快照。
  • 连续多轮没有更新时,运行时可以提醒一次。
1目标与计划
2工具输出增长
3局部错误吸引注意力
4todo 快照重新锚定
点击播放,观察动作怎样传递0 / 4
02 / 正文

顺着原文把边界看清

19 个小节30 组代码6 行表格

按原文顺序阅读。摘要只负责定位,真正的边界、例外和代码都在展开内容里。

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。

可观察的验收结果有四个:

  1. todo_write 每次返回整个 TODO 列表的稳定 JSON 快照,而不是只返回本次变化;
  2. 非法更新不会覆盖旧快照;
  3. 每个 Agent 会话拥有独立状态;
  4. 连续三轮调用其他工具后,下一次模型请求只注入一条临时提醒。

失败边界同样明确: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 reminder

05一、用 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() 都拒绝未知字段。

为什么先 transformpipe?如果直接对原字符串做 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:模型看到的 parametersz.toJSONSchema(definition.inputSchema) 生成,运行时再传给 prepare()safeParse

开发时只维护一套类型契约。模型声明、权限策略和 handler 收到的参数不会因为手工同步而漂移。


06二、一次提交必须是完整快照本章没有设计 todoadd、todoupdate、todoremove 三组增量接口,只提供一个整体替换工具:

本章没有设计 todo_addtodo_updatetodo_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;
}

规则只有三条:

  1. 没有工具调用,不计数;
  2. 本轮只要出现 todo_write,计数归零;
  3. 本轮有工具但没有 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轮后计数
---------------------------:------:
10
2read_file1
3glob2
4shell重置后重新计数

如果第四次请求又产生一个非 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_writeeffect"write",因为它确实改变会话内状态:

effect: "write"

但第 3 章的文件审批规则精确匹配 write_fileedit_file,而不是粗暴拦截所有 write effect:

matches: (request) => {
  const name = request.prepared.definition?.name;
  return name === "write_file" || name === "edit_file";
}

因此更新 TODO 不会弹出磁盘写入审批;它仍然经过统一权限决策并写入审计。这个区分很重要:副作用类别描述行为性质,具体策略决定哪些行为需要人工确认。

第 5 章仍保留已有边界:

  • write_fileedit_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:新增固定 P05todo capability;
  • 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本章小结三句话版本、一定要记住的七条、以及本章「还没做什么」。

三句话版本:

  1. 长任务里丢的不是「目标」,是「进度」——目标在开头出现过一次,进度从来没被显式记录。
  2. 所以本章把进度做成结构化状态:模型每次提交完整快照,快照作为最新的 tool result 回到消息流。
  3. 模型忘记更新时,运行时按「连续三个陈旧工具轮」注入一条只进当次请求的提醒。

一定要记住的七条:

#结论在哪一节
1整体替换没有隐式 merge:漏写一项 = 删除一项
2校验全部通过后才做一次赋值,不存在半更新,也不需要回滚二、九
3readonly 只管编译期,运行时要靠 Object.freeze
4快照和陈旧计数都是会话状态,做成单例会串台
5按 assistant 消息计数,一轮三个调用只算一轮
6「计数到 3」和「注入提醒」不在同一次请求;注入后立刻清零
7提醒进 ModelRequest,不进 #history

本章代码边界(明确「还没做什么」):会话内 TODO 快照不做磁盘持久化(第 9 章文件记忆);不做依赖/blockedBy(第 12、17 章任务 DAG);固定「三轮」阈值而非按内容动态提醒;一条固定文案而非动态生成;提醒只注入当次请求,不进正式历史;不做全局共享 tracker。

检查你是否真的读懂了:

  1. 模型提交 3 项计划,下一次只提交 1 项,另外 2 项会怎样?
  2. 为什么快照要输出 编写 而不是直接输出中文?
  3. TodoTracker 做成单例会出什么问题?至少说两个。
  4. 一条 assistant 消息里有 3 个工具调用,计数加几?
  5. 提醒在第几次模型请求出现?为什么不是第 3 次?
  6. 在 result.history 里能搜到 Keep the TODO list current 吗?为什么?
  7. todo_write 的 effect 是 write,为什么不弹审批框?审计里有东西吗?

03 / 自测

换个场景,你还会判断吗?

答完再看理由

每题只测一个边界。先做决定,再看解释。

SCENARIO CHECK01 / 030 分

准备开始