先观察一次完整的 Agent 运行
先读懂一条从用户目标到工具结果再到最终回答的完整轨迹,为全书建立地图。
- 预计
- 35 分钟
- 难度
- 入门
- 产物
packages/pi-course/src/demo/prologue.ts- 前置
- 无
从 parent 到本章,只增加这一层复杂性
f9798b7c这是 Pi 仓库中的真实可检出提交,不是页面占位符。parent 是本章开始时的干净起点;target 是聚焦测试已经通过的终点。 本章会导出 target 供观察,但不会附带另一份答案或 Git 历史。
- 起点
8479bd84- 目标
f9798b7c- 聚焦测试
packages/pi-course/test/00-prologue.test.ts
npm run checkpoint -w @pi/course -- 00npm run practice -w @pi/course -- 00不要直接给完整答案。先问我对下一次测试输出的预测,然后一次只给一个动作; 再阅读练习目录里的 LEARNING.md。我卡住时按“定位文件 → 指出签名 → 伪代码 → 局部代码”逐级提示。
#你将得到什么
进入本章时,你还没有 Agent,也不需要先理解所有 TypeScript。我们先观察最终成品的一次离线运行:用户要求读取项目说明,模型请求工具,环境返回结果,模型再给出回答。你将得到一张能贯穿全书的地图,并学会用事件轨迹判断系统是否真的闭环。
本章只增加一种复杂性:把“Agent 会思考”改写成可观察的数据流。我们暂时不实现模型和工具。完成后你会观察 packages/pi-course/src/demo/prologue.ts 中的教学 checkpoint;页面随附的 workshop/src/demo/prologue.ts 是全书最终参考实现,不是第一次练习要从空白复刻的答案。你将能解释一条固定轨迹中的每个所有者,并指出一条不能破坏的不变量:
每一个已经作为 assistant content 进入 transcript 的 tool call,最终都必须有一个相同
toolCallId的 tool result。
若想恢复本章起点,不需要回滚代码;重新运行固定脚本即可。它不访问网络,也不修改工作区。
Checkpoint 00 · 先观察,不从空白重写
先跨过从理解到动手的第一步模式: 观察。本章 target 同时建立课程包和固定演示;你不负责在学 TypeScript 前重写这 201 行。
起终点: parent 是本章开始时的起点快照;target 是聚焦测试通过的终点快照。
教学文件: packages/pi-course/src/demo/prologue.ts
第一步: 先不看 target diff 和实现,写下七步轨迹的 owner 预测;随后让陪练打开 target 快照,只比较预测、运行测试并做受控破坏。
聚焦测试: packages/pi-course/test/00-prologue.test.ts
定位命令: npm run checkpoint -w @pi/course -- 00
练习目录: npm run practice -w @pi/course -- 00
聚焦运行: npm run build -w @pi/course,然后 node --test packages/pi-course/dist/test/00-*.test.js
通过证据: 聚焦测试 2/2 通过;你能区分事件的 owner、发起者和环境动作执行者,并说明为什么本章没有要求你重建实现。
第一次学习禁止让 Agent 粘贴完整答案;00 章只在预测之后查看 target,01 章才从 parent 开始重建。
这里的 packages/pi-course/ 是你和陪练使用的引导重建历史;workshop/ 是教材自身经过全量测试的最终参考实现。第一次学习不要在两棵目录之间来回复制代码。
#先建立全景
假设用户说:“读取 README.md,用一句话告诉我这个项目做什么。”离线演示产生下面的稳定轨迹:
01 user_message "读取 README.md,并概括项目"
02 model_start
03 assistant_message stopReason=toolUse
toolCall id=call_1 name=read arguments={"path":"README.md"}
04 tool_start id=call_1
05 tool_result id=call_1 isError=false content="# tiny-pi ..."
06 model_start
07 assistant_message stopReason=stop
text "这是一个用于学习 Agent 内核的 TypeScript 项目。"model_start 与 tool_start 是为了观察运行过程而记录的运行轨迹事件,不是稍后会持久化的 AgentMessage。Canonical AgentMessage 只保存 user、完成的 assistant 和 tool result 这些对下一轮仍有意义的事实。现在不需要理解 AgentContext、EventStream、provider 或 Usage 的实现;它们只是地图上的站名,会在后续章节各自出现一次。
这里没有神秘的“自主性”。Agent 只是反复回答三个问题:当前事实是什么、模型下一步请求什么、这个请求应当由谁执行。模型拥有生成内容的责任,却不拥有文件系统;工具拥有环境副作用,却不能决定下一轮提示;循环拥有顺序和终止责任,却不应理解 read 的业务细节。序章播放的是固定 fixture:tool_result 的内容已经写在轨迹里,不执行真实的文件读取,也不会改动你的工作区。
阅读轨迹时要区分“系统已经观察到的事实”和“我们根据最终答案做出的猜测”。第 03 行只能证明模型请求读取;第 04 行只能证明执行开始;直到第 05 行出现,读取结果才成为可供下一轮使用的环境事实。若只看第 07 行,我们甚至无法排除模型凭训练记忆猜中答案。后续测试因此不会只断言最后一句字符串,还会检查中间消息、配对键和 stop reason。这种检查方式会贯穿全书:先找首次偏差,再讨论最终表现。
运行前先判断
先写判断,再看推理如果删掉第 05 行,但仍把第 03 行的 tool call 和第 07 行的最终文本保存下来,下一轮模型能否可靠知道文件读取成功?
展开参考推理
不能。tool call 只是动作请求,不是环境事实。缺少配对的 tool result 后,模型既不知道动作是否执行,也不知道得到什么;“最终文本看起来合理”不能修补这段断裂的因果链。
#用消息而不是界面描述事实
终端会把事件渲染成几行文字,但界面不是事实源。后续章节会建立统一消息语言;现在先看它的最小形状:
type AgentMessage =
| { role: "user"; content: TextContent[]; timestamp: number }
| {
role: "assistant";
content: (TextContent | ToolCall)[];
provider: string;
model: string;
usage: Usage;
stopReason: "stop" | "length" | "toolUse" | "error" | "aborted";
errorMessage?: string;
timestamp: number;
}
| {
role: "toolResult";
toolCallId: string;
toolName: string;
content: TextContent[];
details?: unknown;
isError: boolean;
timestamp: number;
};同一份 AgentMessage[] 可以被终端打印、写入 JSONL、变成下一次模型请求,或在网页中折叠显示。若反过来把彩色终端字符串当历史,颜色、换行和日志前缀就会混入语义,恢复会话时也无法可靠找到 tool call。
三个所有者
把现象连接到不变量这里的 owner 表示“谁产生这条事件记录所代表的事实”,不等于“谁发起外层函数调用”。例如 loop 发起一次模型调用,但 model_start 仍属于模型生命周期;模型提出 read,但 tool_start 是 loop 的调度记录;真正的观察结果才属于 tool。Agent loop 还负责把这些事实按协议追加进 transcript。把责任分开后,任何错误都能先定位到“生成、执行、编排”中的一层。
实践 0.1 · 给轨迹标注所有者
在真实文件中建立能力目标: 把“智能行为”拆成可检查的责任。
文件: packages/pi-course/src/demo/prologue.ts
动作:
- 先不看实现,为七行轨迹分别标记
user / model / loop / tool。 - 运行演示,比较你的标注与事件的
owner字段。 - 指出真实运行中哪一步会造成环境副作用,再确认固定 fixture 为什么没有真的执行它。
运行: npm test -w @pi/course
预期: 测试验证固定的七步轨迹;真实系统中 read 工具接触环境,但本章 fixture 只回放结果,模型和演示都没有读文件。
#从终点倒推我们要造的部件
完整运行可以压缩成一条重复路径:
用户目标
→ AgentContext
→ Model.stream()
→ EventStream<ModelEvent, AssistantMessage>
→ tool call
→ 工具执行与 tool result
→ 更新 AgentContext
→ 下一次 Model.stream()
→ stop第一部会造 AgentContext、消息 IR、EventStream、ScriptedModel 和真实 provider 边界;第二部才闭合工具与循环;第三部让运行可取消、可保存、可恢复;第四部再处理扩展、产品入口和系统评测。这个顺序不是仓库导览,而是每次只让一个未知量进入系统。
注意这条主链会重复出现,而不是一章讲完就被丢弃。第一次它只是固定 trace;加入 EventStream 后能观察时间;加入消息 IR 后能保存语义;加入工具后才真正接触环境;加入 session 后可以恢复与分支。每一次重走同一条路,旧部件都要在新约束下再次证明自己。你最终记住的应当是稳定因果关系,而不是某个版本的文件树。
实践 0.2 · 用缺失结果暴露断裂
在真实文件中建立能力目标: 证明你检查的是 call/result 因果关系,而不是最后一句回答。
文件: packages/pi-course/test/00-prologue.test.ts
动作:
- 先读第二个测试:它删除
tool_result,随后按新数组位置重新编号。 - 运行前预测验证器会先报“步骤号”还是“缺少配对结果”。
- 测试通过后,只在纸上把 result 移到 call 前并重新编号;预测它会变成哪一种配对前置错误,不需要在尚未学习 TypeScript 时另写测试。
运行: npm test -w @pi/course
预期: 仓库测试稳定构造“缺少配对结果”;纸面提前 result 应被判断为“结果先于调用”。两者都先于最终文本断言。
#故意把它弄坏
把工具错误伪装成成功文本,是最危险的“看起来还能跑”。例如读取不存在的文件时,下面这条消息保留了配对关系,也保留了失败事实:
{
"role": "toolResult",
"toolCallId": "call_1",
"toolName": "read",
"content": [{ "type": "text", "text": "ENOENT: README.md" }],
"isError": true
}预期失败 · 删除配对结果
寻找第一次偏差删除 tool_result 后重新编号,再运行测试。首次偏差应是 call_1 没有结果,而不是步骤号或“回答文字不同”。若测试只检查最后一句话,它会错误放行一条断裂轨迹;先修验收证据,再恢复事件。
#本章验收
Checkpoint 00 · 能画出闭环
以证据进入下一状态你应能不看正文画出 user → model → tool call → tool result → model → stop,并分别回答每一步的 owner、发起者与环境动作执行者。在 practice 00 创建的目录中先 build,再运行 node --test packages/pi-course/dist/test/00-*.test.js,应有 2 个测试通过。恢复方法是重新创建 target 快照,不设置 API key,也不编辑相邻目录。下一章才从 parent 开始动手补齐 TypeScript 生存集。
#可选迁移练习
迁移 · 换成失败的 Bash
完成引导重建后再减少脚手架不修改主轨迹代码,另写一条五到七步的纸面轨迹:模型请求 bash,命令退出码为 2,模型据此解释失败。标出 call/result 配对和 isError。不要写“模型执行命令”;若能保持所有权与失败事实,你已把不变量迁移到另一个工具。
#小结
Agent 的最小本质不是聊天界面,而是由类型化事实连接起来的反馈回路。消息保存语义,模型提出下一步,工具接触环境,循环维护顺序。序章只让你看见终点;从下一章开始,我们会逐件造出它,并让每个部件都能被测试、破坏和恢复。
完成验收后再点亮本章
阅读进度只保存在这台设备;本章证据是聚焦测试、commit diff 与你对首次偏差的解释。 迁移练习是熟练后的可选挑战。