最近我花了两个小时学习开源项目 PI from Scratch。它用一个非常小的 TypeScript 项目,演示了 Coding Agent 如何读取文件、修改代码和执行命令。
这次最有价值的收获,不是记住了多少行代码,而是终于把一个 Agent 的最小闭环想清楚了:
模型决定下一步,Agent 推进循环,工具改变环境,Context 保存状态,界面负责展示。
本文记录我的理解过程,也整理几个一开始容易混淆的概念:StreamEvent 与 AgentEvent、max_tokens 与 compaction,以及为什么新增工具不需要修改 Agent Loop。

一、先看地图:五个文件分别负责什么
nano-pi 的核心可以拆成五个 TypeScript 文件。
1. llm.ts:屏蔽模型供应商协议
llm.ts 接收模型配置、Context、工具描述和取消信号,调用 OpenAI 兼容 API,并逐行解析 SSE 流。
它会把供应商返回翻译成统一的 StreamEvent:
text_delta:模型生成了一段文本;tool_call:模型决定调用工具;done:本轮模型输出结束;error:请求或解析失败。
流式返回的工具参数可能分成很多片段,因此 llm.ts 还要先缓存和拼接参数,再交给上层。
2. agent.ts:真正的 Agent Loop
agent.ts 是核心编排层。它负责:
- 把当前 Context 交给 LLM;
- 收集文本与工具调用;
- 把模型回复写回 Context;
- 执行工具;
- 把工具结果写回 Context;
- 再次询问模型,直到没有工具调用。
它还会输出面向界面的 AgentEvent:
assistant_texttool_calltool_resultturn_end
3. tools.ts:真正改变环境
tools.ts 定义读文件、写文件、局部编辑和执行命令等工具。
模型产生的 tool_call 只是结构化的行动请求,并没有真的读写文件。真正的副作用发生在工具的 execute() 中。
每个工具遵守同一接口:名称、描述、参数 Schema 和执行函数。这使得 Agent 可以面向抽象工具工作,而不依赖某个具体实现。
4. tui.ts:输入与展示
tui.ts 读取用户输入、展示模型文本与工具事件,并处理 Ctrl+C。它不知道 LLM 怎样调用,也不知道工具怎样执行,只认识 AgentEvent。
因此,把终端换成 Web 页面时,不需要重写 Agent Loop,只需要换一个事件消费者。
5. cli.ts:装配入口
cli.ts 是 Composition Root,也就是"粘合剂"。它读取环境变量和历史会话,创建 TUI,取得工具集合,监听用户输入,调用 runAgent(),再把 Agent 事件转发给界面。
它还负责把用户消息写入 Context,以及在一轮结束后持久化会话。
二、Agent 的本质:模型与环境之间的反馈闭环
把界面和工程细节暂时拿掉,最小 Agent Loop 可以写成:
text
while true:
必要时压缩 Context
调用 LLM,收集文本和 tool_calls
将模型回复写入 Context
如果没有 tool_call:结束本轮
依次执行工具
将 tool_results 写入 Context
进入下一轮
关键不是 while,而是工具结果必须重新进入 Context。
模型不能直接看到文件系统。它只知道自己提出了 read_file 请求,却不知道工具是否执行成功、读到了什么。只有 Agent 把 tool_result 放进下一次请求的 Context,模型才能基于真实环境反馈继续推理。
三、用四个 Context 快照理解一次 read_file
假设用户输入"读取 hello.txt"。同一个消息数组会逐步变成:
text
C1 [U1]
用户请求读取文件
C2 [U1, A1]
模型输出说明文字和 tool_use(read_file)
C3 [U1, A1, U2]
Agent 执行工具,写入 tool_result("hello world")
C4 [U1, A1, U2, A2]
模型看到结果,给出最终回答
这四个"快照"不是四份 Context,而是同一个状态容器不断增长。
我现在更愿意把 Context 理解成 Agent 的工作记忆和状态日志:模型每次决策,都只能基于当前被送入请求的那一份状态。
四、为什么要区分 StreamEvent 和 AgentEvent
一开始看两套事件会觉得重复。实际它们处在不同抽象层:
StreamEvent描述模型协议发生了什么;AgentEvent描述 Agent 应用发生了什么。
例如模型供应商可能把一次工具调用拆成多个 SSE 分片,但界面并不需要知道拼接细节。界面只关心"Agent 要调用哪个工具""工具返回了什么""这一轮是否结束"。
这层翻译同时隔离了两个变化方向:上游可以更换模型 Provider,下游可以更换 TUI 或 Web UI。
五、四类边界情况背后的状态不变量
1. max_tokens:不要执行被截断的工具参数
max_tokens 限制模型这一次能输出多少内容。如果恰好在工具参数 JSON 中间截断,拿残缺参数执行可能造成错误,甚至产生破坏性副作用。
因此,当输出因 max_tokens 截断且包含工具调用时,保守做法是不执行这些调用,而是写入错误结果,让模型下一轮重新生成完整参数。
2. compaction:解决 Context Window 容量
compaction 与 max_tokens 不是一回事。
max_tokens:单次模型输出被截断;- compaction:历史输入不断增长,快要超过 Context Window。
nano-pi 用消息条数作为粗略阈值,把旧消息交给 LLM 总结,用一条摘要替换旧历史,同时保留最近消息。消息数只是实现上的近似,真正受限的是 Token 容量。
3. abort:取消之后仍要保持消息配对
Ctrl+C 会触发 AbortController,取消正在进行的模型请求或工具进程。
如果一批工具调用只执行了一部分,剩余调用不能直接从历史中悬空。每个 tool_call 都应有对应的 tool_result;未执行的调用要补上 error: aborted。否则下一次请求的消息协议不完整,API 可能拒绝,模型也无法判断实际执行状态。
4. 工具报错:错误也是一种观察
工具报错不一定意味着整个程序崩溃。把错误转换成结构化或字符串形式的 tool_result,模型就能基于失败原因修正参数、选择其他工具或向用户解释。
由此可以总结三条循环不变量:
- 模型回复必须进入 Context;
- 每个
tool_call必须配对tool_result; - 只有没有待处理调用或遇到明确终止条件时,当前 Turn 才能结束。
六、为什么增加 list_files 不用改 Agent Loop
假设我要增加一个列目录工具,只需定义:
ts
{
name: "list_files",
description: "列出指定目录中的文件",
parameters: {
type: "object",
properties: {
path: { type: "string" }
},
required: ["path"]
},
execute: async ({ path }) => {
// 返回目录内容
}
}
然后把它注册进 builtinTools()。
CLI 会把完整工具集合注入 Agent。Agent 只按名称找到工具,再调用统一的 execute(),所以不需要为 list_files 写一段新的循环分支。
这其实是开闭原则的一个很小但直观的例子:扩展系统能力时增加实现,不修改稳定的编排核心。
七、这次学习纠正了什么
我最初有几个模糊点:
- 把
max_tokens与 compaction 混在一起; - 把 abort 理解成再向模型发送一个"终止状态";
- 没有区分"模块依赖谁"和"运行时数据经过谁";
- 知道工具可以扩展,但没说清为什么 Agent Loop 无需修改。
通过追踪 Context 和边界情况,这些问题最终都落到了同一个主题:Agent 工程的核心不是让模型多想几步,而是维护一个一致、可继续推进的状态闭环。
如果状态不完整,模型再强也无法可靠继续;如果工具和界面通过稳定协议解耦,系统才容易扩展和测试。
八、下一步
最好的巩固方式不是再读一遍文章,而是亲手实现 list_files:
- 定义工具 Schema 与执行逻辑;
- 注册到工具集合;
- 补充成功、目录不存在和 abort 测试;
- 确认
agent.ts不需要任何修改。
你在理解 Agent 时,最容易混淆的是模型、Agent Loop、工具还是 Context?欢迎分享你的理解和实践经验。