从五个 TypeScript 文件看懂 Coding Agent:一次 nano-pi 学习复盘

最近我花了两个小时学习开源项目 PI from Scratch。它用一个非常小的 TypeScript 项目,演示了 Coding Agent 如何读取文件、修改代码和执行命令。

这次最有价值的收获,不是记住了多少行代码,而是终于把一个 Agent 的最小闭环想清楚了:

模型决定下一步,Agent 推进循环,工具改变环境,Context 保存状态,界面负责展示。

本文记录我的理解过程,也整理几个一开始容易混淆的概念:StreamEventAgentEventmax_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 是核心编排层。它负责:

  1. 把当前 Context 交给 LLM;
  2. 收集文本与工具调用;
  3. 把模型回复写回 Context;
  4. 执行工具;
  5. 把工具结果写回 Context;
  6. 再次询问模型,直到没有工具调用。

它还会输出面向界面的 AgentEvent

  • assistant_text
  • tool_call
  • tool_result
  • turn_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,模型就能基于失败原因修正参数、选择其他工具或向用户解释。

由此可以总结三条循环不变量:

  1. 模型回复必须进入 Context;
  2. 每个 tool_call 必须配对 tool_result
  3. 只有没有待处理调用或遇到明确终止条件时,当前 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

  1. 定义工具 Schema 与执行逻辑;
  2. 注册到工具集合;
  3. 补充成功、目录不存在和 abort 测试;
  4. 确认 agent.ts 不需要任何修改。

你在理解 Agent 时,最容易混淆的是模型、Agent Loop、工具还是 Context?欢迎分享你的理解和实践经验。

相关推荐
m4Rk_1 小时前
【论文阅读】Agent 记忆机制(40):HiAgent——通过子目标级记忆提升长程任务执行能力
论文阅读·人工智能·学习·开源·github
摸鱼研究员1 小时前
neverthrow,ts 中优雅的异常处理方案
前端·javascript
烬羽1 小时前
求第 K 大,为什么反而要用最小堆?
javascript·数据结构·算法
jingchao19981 小时前
Cannot read properties of null (reading ‘insertBefore‘)
前端·javascript·vue.js
richard_yuu2 小时前
JSON vs XML vs 二进制:3种序列化终极选型
xml·c++·qt·学习·json
迪丽热爱2 小时前
多媒体应用5-817
学习
世人万千丶2 小时前
收纳格卡片风:ArkUI 让鸿蒙物品清单像收纳盒贴标
学习·华为·harmonyos·鸿蒙
梦醒沉醉2 小时前
2、JavaScript控制流和错误处理
javascript
MartinYeung52 小时前
[论文学习]X-Boundary:为LLM建立精确安全边界以抵御多轮越狱攻击
学习·安全