从 0 开始构建一个 Code Agent

这是一门可以运行、测试和逐步修改的源码教程。你会从一次原生 HTTP 请求开始,依次加入 Agent Loop、Tool Use、Permission、Hooks、Subagent、Skill、Context Compaction、Memory、Task、Background Task、Agent Teams 和 MCP。

前置知识只有 JavaScript、Node.js 和基本命令行操作。完成 19 节后,你不仅能写出一个最小 Code Agent,还能解释教学实现与商业级 Sandbox、Durable Runtime、Observability 和 Eval 平台之间的差距。

s00.1:搭起文件骨架

这一系列教程最终要写出一个真正能干活的 Code Agent:它可以执行命令、读写文件、拆分任务,甚至和其他 Agent 协作。

不过,现在先把这些能力全部放下。我们从最小的一步开始:让一个 Node.js 程序把问题发给大模型,再把模型的回答打印到终端。

你平时在聊天网页里输入一句话,很快就能看到模型回复。对程序来说,这个过程并不神秘:程序发送 messages,等待 HTTP 响应,再读取其中的 assistant message。

这一讲结束时,我们会得到一个最小的命令行聊天程序。你在终端输入问题,它会把模型返回的文本打印出来。

它还不是 Agent,但后面所有能力都会建立在这次请求之上。

shareAI-lab/learn-claude-code 从 Agent Loop 开始。本教程先增加 s00,把最底层的 HTTP 调用讲清楚,再进入循环和工具。

问题

大模型不是安装在当前程序里的普通函数。它运行在模型服务商的服务器上,我们必须先回答四个问题:

  1. 请求发到哪个地址?
  2. 用哪个 API Token 证明身份?
  3. 调用哪个模型?
  4. 用户和模型的对话用什么格式传输?

这些问题没有解决之前,后面的 Agent Loop、工具调用和权限系统都无从谈起。

所以 s00 只完成一条最短链路:用户输入先变成 messages,通过 HTTP POST 发给模型,最后变成 assistant 文本。

这一讲故意不加入工具。我们先确认程序能稳定地“听见”模型,下一讲再让它真正行动。

解决方案

我们把代码分成两层。

第一层是公共底座 helper.js,负责保存模型配置、发送 HTTP 请求和读取命令行输入。后面的每一讲都会继续复用它。

第二层是本章入口 s00-llm-http.js,它只负责把当前对话交给模型,再取出文本回答。

这里最重要的边界是:runChatCli 负责对话,callLlm 负责 HTTP,ask 负责把两者接起来。

新增函数速览

  • callLlm(messages):读取当前模型配置,通过 HTTP 发送 messages,返回 choices[0]。
  • runChatCli(options):等待终端输入,维护多轮 messages,再打印每次模型回复。
  • ask(messages):调用 callLlm(),从 choice.message.content 取出本章需要的文本。
  • main():把 ask() 接入命令行交互。
  • isMainModule(import.meta.url):只有直接运行文件时才启动 CLI,测试导入时不会自动等待输入。

先有一个整体印象即可。接下来,我们按照程序真正执行的顺序逐步看代码。

适合本节测试的 prompt

用两句话解释什么是 Code Agent,并举一个文件操作例子。

这一讲只观察模型能否返回文本。即使回答里提到“创建文件”,程序也不会真的创建文件,因为我们还没有提供任何工具。

第一步只做两件事:从 helper.js 导入三个函数,并写下章节注释。依赖关系先就位,业务代码后面再填。

本次代码变化:8 行。

s00.2:新增 ask:取回模型文本

ask() 是本章业务的核心:调用 callLlm() 发送 messages,从 choice.message.content 取出普通文本。模型正常返回时把 content 交给调用方,内容为空则返回空字符串。

本次代码变化:7 行。

s00.3:接到命令行

main() 把 ask() 交给 runChatCli():每轮等待输入、把输入加进 messages、调用 ask()、打印回答。多轮对话自然保存在同一个 messages 数组里。

本次代码变化:8 行。

s00.4:加上启动守卫

最后用 isMainModule() 包住 main():直接执行 node s00-llm-http.js 才启动命令行,被测试导入时不会自动等待输入。到这里,代码与本章真实 JS 文件逐字一致。

本次代码变化:9 行。

s01.1:引入 runBash

现在这个聪明新人已经能听懂你说话了,但他还容易“做一步就停下来汇报”。s01 要教他的第一条工作习惯是:遇到任务先自己循环推进,把工具结果看完再继续,直到真的完成再汇报。

参考:shareAI-lab/learn-claude-code 中的 s01_agent_loop。

参考教程的核心句子是:一个工具 + 一个循环 = 一个 agent。

我们的 JS 版延续这个思路:只给模型一个 bash 工具,然后写一个 while (true),让模型可以持续行动。

问题

普通聊天里,模型只能输出文本。

比如你问:

模型也许会告诉你应该运行:

但它不会真的执行,也看不到执行结果。

你可以手动把命令复制到终端,再把结果粘回对话框。这个“复制、执行、粘贴”的过程,就是人工版 agent 循环。

s01 要做的事,就是把这个中间层自动化。

解决方案

给模型一个真实工具:bash。

s01 Agent Loop 架构图

Python 原版源码里把整个结构浓缩成了这个模式:

放成完整结构图就是这样:

整个循环只看一个信号:

信号含义动作
有 message.tool_calls模型想调用工具执行工具,结果回灌,继续
没有 message.tool_calls模型给最终回答结束循环

参考实现提醒

参考 Python 版时,最容易漏掉的不是 while,而是这三个不变式:

  • 先保存模型那条带 tool_calls 的 assistant message,再保存工具结果。
  • 每个工具结果都要带回同一个 tool_call_id,否则模型不知道结果对应哪次调用。
  • 退出条件不是“跑了一轮”,而是模型不再请求工具,返回普通文本。

新增函数速览

  • agentLoop(messages):本章核心函数,反复执行“调用模型 → 检查 tool_calls → 执行工具 → 回灌工具结果”,直到模型返回普通文本。
  • runBash(command):来自 helper.js,执行模型请求的 bash 命令,并把真实 stdout / stderr 作为工具结果返回。
  • main():命令行入口,把用户输入交给 agentLoop(),让这个最小 agent 可以在终端里跑起来。

测试 Prompt

列出当前目录下所有 JavaScript 文件,并告诉我一共有多少个
列出当前目录下所有 JavaScript 文件,并告诉我一共有多少个

观察重点:模型应该先调用 bash,拿到真实命令结果后,再基于工具结果回答。

工具实现都放在 helper.js 里,本章要做的第一件事,就是让程序认识 runBash:先在 import 里把它加进来。这一步不改变任何行为,只是为后面的工具调用做准备。

本次代码变化:1 行。

s01.2:声明 SYSTEM 提示词

把章节注释换成 s01 的说明,并新增 SYSTEM 系统提示词:告诉模型“你是当前目录里的 coding agent,任务是用 bash 干活,不要只解释”。

本次代码变化:6 行。

s01.3:声明 bashTool schema

新增 bashTool:一份给模型看的 schema。它不执行任何代码,只是告诉模型“存在一个叫 bash 的函数,接收必填的 command 字符串”。模型看到这份声明,才能在回答里返回工具调用请求。

本次代码变化:15 行。

s01.4:搭起 agentLoop 骨架

新增 agentLoop 的骨架:while (true) 反复调用模型,只要模型不再请求工具(没有 message.tool_calls)就返回答案。这一步先不写工具执行,main 也还指向 ask,所以程序行为不变,仍然可运行。

本次代码变化:13 行。

s01.5:执行工具并回灌结果

补全 agentLoop 的核心闭环:模型请求工具时,先把它那条 assistant message 放回 messages,再逐个执行 tool_calls,用同一个 tool_call_id 把结果作为 tool 消息放回 messages,循环继续。注意 function.arguments 是 JSON 字符串,必须 JSON.parse。

本次代码变化:12 行。

s01.6:切换 main 入口,删掉 ask

最后把命令行入口从 ask 切到 agentLoop,promptLabel 改成 s01 >>,并移除只属于 s00 的 ask 函数和多余注释。到这里,程序行为完整过渡到 s01:模型返回工具调用 → 执行 bash → 回灌结果 → 继续循环。

本次代码变化:14 行。

s02:Tool Use —— 多加一个工具,只加一个 handler

新人会跑命令以后,领导又让他去读文件、写文件、查列表。s02 的故事就是给他发一套工具箱:每新增一种工具,只要告诉他“工具叫什么、参数是什么、真正执行时找谁处理”。

参考:shareAI-lab/learn-claude-code 中的 s02_tool_use。

参考教程的核心是:循环不用变复杂,新工具注册进 dispatch map 就行。

我们的 JS 版沿用同一个设计:tools 给模型看,handlers 给程序执行。

问题

s01 只有一个 bash。

这当然能做很多事,但模型每次都要把真实意图翻译成 shell:

模型真正想做只能通过 bash 表达
读文件cat file
写文件echo ... > file
改文件sed ...
找文件find ...

这会浪费 token,也容易让模型拼错命令。

更大的问题是:如果每新增一个工具,都在 loop 里加一段 if/else,主循环很快会变乱。

解决方案

把工具分成两张表:

s02 工具分发架构图

s02 工具并发对比图

新增工具只做两件事:

  1. 在 tools 里增加 schema。
  2. 在 handlers 里增加同名函数。

agent loop 仍然只负责查表分发。

参考实现提醒

参考实现里还有两个细节值得保留在脑子里:

  • tools 是给模型看的 schema,handlers 才是本地真正执行的函数,二者只通过工具名对齐。
  • 模型一次可能返回多个 tool_calls,循环要逐个执行、逐个回灌,不要只处理第一个。
  • 教程版先串行执行所有工具;后面真做并发时,要先判断工具是否安全并发。

新增函数速览

  • agentLoop(messages):循环结构基本沿用 s01,但工具执行不再写死判断,而是通过 handlers 分发表找到对应实现。
  • tool(name, description, parameters):来自 helper.js,用一行代码生成 OpenAI-compatible 的 function tool schema。
  • readFile() / writeFile() / editFile() / glob():来自 helper.js,分别提供读文件、写文件、替换文本和查找文件能力。

测试 Prompt

创建 s02-tmp-note.txt,写入 hello s02,然后读出来确认内容。
创建 s02-tmp-note.txt,写入 hello s02,然后读出来确认内容。

观察重点:模型应该按顺序调用 write_file 和 read_file,主循环仍然只走统一分发表。

s03:Permission —— 执行前做权限判断

新人越来越能干,但也可能一激动就想执行危险操作,比如删除文件或改系统配置。s03 就像公司给他配了一道审批门:不是不信任他,而是所有可能造成破坏的动作,都要先过规则和人工确认。

参考:shareAI-lab/learn-claude-code 中的 s03_permission。

参考教程的核心是:安全不能靠相信模型,要靠代码在工具执行前做判断。

我们的 JS 版也保持这个思路:s02 的多工具 loop 不变,只在执行 handler 前插入一个权限门。

问题

s02 之后,agent 有了 5 个工具。

这让它更能干,也更危险。

比如用户说:

模型可能会生成:

也可能生成:

这两种都和删除有关,但风险不一样。前者应该直接拒绝,后者也许可以执行,但最好先问用户。

解决方案

在每次工具执行前,加一道三档权限门:

s03 权限系统总览图

s03 权限执行流水线图

三档结果:

决策含义例子
allow直接放行read_file、glob、普通命令
deny永远拒绝rm -rf /、sudo、shutdown
ask先问用户rm notes.txt、chmod 777

这里的目标不是做完美安全沙箱,而是建立 agent runtime 的关键边界:

模型可以提出行动意图,但执行前必须经过系统判断。

参考实现提醒

权限系统不是为了“拦住所有危险”,而是把执行前的边界放到代码里:

  • 硬拒绝优先级最高,命中后不能被后面的确认流程绕过。
  • ask 不是失败,而是把风险决策交还给用户。
  • 拒绝结果也要作为 tool result 回灌,让模型知道刚才那条路走不通。

新增函数速览

  • checkPermission(name, args):本章新增的权限门,按工具名和参数返回 allow、ask 或 deny。
  • approved(confirm, reason, name, args):当权限结果是 ask 时,向用户询问是否允许执行。
  • agentLoop(messages, confirm):在 s02 的循环基础上,执行工具前先经过 checkPermission()。

测试 Prompt

运行 rm -rf /
运行 rm -rf /

观察重点:危险命令应该在执行前被权限系统拒绝,而不是交给 shell 真的运行。

s04:Hooks —— 挂在循环上,不写进循环里

新人开始承担更多流程:干活前要打卡,干活后要记录,收工前要总结。如果每条规矩都写进他的主工作步骤里,流程会乱成一团;s04 就是给工作流预留挂钩,让这些规矩挂在固定节点上。

参考:shareAI-lab/learn-claude-code 中的 s04_hooks。

参考教程的核心是:扩展逻辑应该挂在 hook 上,而不是不断写进 agent loop。

我们的 JS 版延续这个思路:保留 s03 的权限能力,但把权限判断变成 PreToolUse hook。

问题

s03 已经能在工具执行前做权限判断。

但如果后面继续加机制,比如:

  • 记录每次工具调用
  • 工具执行后检查输出大小
  • 用户输入后注入上下文
  • agent 结束时做统计
  • 后续章节里的 todo、skill、memory、compact

如果这些都写进 agentLoop(),主循环很快会变成这样:

循环会越来越难读。

所以 s04 要做的事是:给主循环留插口。

解决方案

定义一组生命周期事件,然后允许外部注册回调。

s04 Hook 系统总览图

Python 原版源码里的完整结构图是这样:

主循环只负责在关键时刻触发 hook:

具体要做什么,交给注册进去的 callback。

参考实现提醒

Hook 的价值不是“多一层回调”,而是把扩展点固定下来:

  • PreToolUse 可以拦截工具,但不能绕过更高优先级的安全规则。
  • PostToolUse 适合做日志、输出检查、统计,不应该改变已经发生的工具动作。
  • Stop 是收尾点,不是随便结束;真实系统里常用它做总结、记忆写入或强制续跑判断。

新增函数速览

  • registerHook(event, callback):把一个回调挂到指定生命周期事件上。
  • triggerHooks(event, ...args):按顺序执行某个事件上的 hook;如果 hook 返回结果,就可以拦截或改写流程。
  • permissionHook(name, args, confirm):把 s03 的权限判断搬进 PreToolUse 生命周期。
  • logHook() / largeOutputHook() / summaryHook() / contextHook():分别演示日志、输出检查、结束统计和用户输入前处理。

测试 Prompt

尝试删除 notes.txt,然后告诉我这次操作有没有被拦截。
尝试删除 notes.txt,然后告诉我这次操作有没有被拦截。

观察重点:拦截逻辑应该来自 PreToolUse hook,工具执行后还会经过 PostToolUse hook。

s05:待办写入

新人接到复杂任务时,最怕脑子里一团线:先做什么、做到哪了、还有什么没做都不清楚。s05 教他先写备忘录,把任务拆成可见的 todo,这样执行时不容易跑偏。

本章目标

让 agent 在多步骤任务开始前,先把计划写出来。

这一章只新增一个能力:todo_write。它不执行外部动作,只把当前任务拆成可见列表,避免模型做着做着忘记主线。

新增能力

上一章已经有了 hook 和权限门。s05 在此基础上加了两件小东西:

  • todo_write:模型用它写入当前计划。
  • roundsSinceTodo:如果模型太久不更新计划,就往上下文里塞一条提醒。

这里不需要 normalizeTodos 这类详细校验。工具 schema 已经告诉模型 todo 的形状,教程重点是“计划如何进入 agent loop”,不是把输入校验系统做完整。

参考实现提醒

TodoWrite 容易被误解成“任务系统”,但这一章还不是:

  • todo_write 只维护当前会话里的可见计划,不负责跨会话持久化。
  • todo 的状态变化是给模型和用户看的工作记忆,不等于文件已经真的改完。
  • <reminder> 只是注入给模型看的提醒标签,没有 HTML 语义,也不会被浏览器解析。

新增函数速览

  • getTodos():读取当前内存里的 todo 列表,方便测试和观察 agent 的计划状态。
  • runTodoWrite({ todos }):todo_write 的实际处理函数,把模型给出的 todo 列表保存到 currentTodos。
  • agentLoop(messages, confirm):在 s04 的 hook loop 基础上,增加 todo 更新计数和 <reminder> 提醒。

流程图

s05 Todo 写入架构图

用户输入
|
v
agent loop
|
+--> 太久没更新 todo?
| |
| +--> 注入提醒: <reminder>Update your todos.</reminder>
|
v
LLM 决定下一步
|
+--> 调用 todo_write
| |
| +--> 写入 currentTodos
| +--> roundsSinceTodo = 0
|
+--> 每个包含工具调用的模型回合
|
+--> roundsSinceTodo + 1
用户输入
|
v
agent loop
|
+--> 太久没更新 todo?
| |
| +--> 注入提醒: <reminder>Update your todos.</reminder>
|
v
LLM 决定下一步
|
+--> 调用 todo_write
| |
| +--> 写入 currentTodos
| +--> roundsSinceTodo = 0
|
+--> 每个包含工具调用的模型回合
|
+--> roundsSinceTodo + 1

测试 Prompt

用 todo_write 规划:创建、读取、更新 notes.txt;创建含三条⬜任务的 notes.txt,读取后把文件和 todo 都改成全部✅。
用 todo_write 规划:创建、读取、更新 notes.txt;创建含三条⬜任务的 notes.txt,读取后把文件和 todo 都改成全部✅。

观察重点:模型应该先调用 todo_write 写计划,再穿插文件工具,最后同时更新文件内容和 todo 状态。

s06:Subagent —— 把探索放进干净上下文

新人遇到一堆资料要查时,如果所有草稿、试错和中间结论都塞进主汇报里,领导很快看不清主线。s06 像是让他临时拉一个助理去单独调研,最后只把摘要带回来。

本章目标

前面几章里,主 agent 既要负责主任务,又要负责探索细节。探索一多,主对话就会变乱。

s06 加入一个 task 工具:主 agent 把一段子任务交给 subagent,subagent 用自己的 messages[] 跑完,只把最终摘要交回主对话。

新增能力

本章不是替换 s05 的计划能力,而是在保留 todo_write 的基础上,新增子代理。

  • task:启动一次性 subagent。
  • subagent 有独立上下文。
  • subagent 可以使用基础工具。
  • 父 agent 只收到最终摘要,不收中间过程。

为什么这一步重要

subagent 的重点不是“多一个人”,而是“隔离上下文”。

主 agent 不需要把所有搜索、试错、读文件细节都背在当前对话里。它只需要拿到一句可靠结论,然后继续推进主目标。

参考实现提醒

Subagent 的关键不是“再开一个函数”,而是上下文隔离:

  • 子 agent 用新的 messages[] 开始,父 agent 不继承它的中间推理和工具流水。
  • 父 agent 只拿最终摘要,避免主上下文被探索过程污染。
  • 教程版通常禁止子 agent 再继续创建子 agent,防止递归失控。

新增函数速览

  • spawnSubagent(description, confirm):用一份新的 messages[] 启动子代理,让探索性任务在干净上下文里运行。
  • agentLoop(messages, confirm):在 s05 基础上支持异步 handler,所以 task 工具可以等待子代理完成后再回灌摘要。

流程图

s06 Subagent 架构图

s06: Subagent — spawn sub-agents with fresh messages[] for context isolation.
Parent Agent Subagent
+------------------+ +------------------+
| messages=[...] | | messages=[task] | <-- fresh
| | dispatch | |
| tool: task | ---------------> | own while loop |
| prompt="..." | | bash/read/... |
| | summary only | (max 30 turns) |
| result = "..." | <--------------- | return last text |
+------------------+ +------------------+
^ |
| intermediate results DISCARDED |
+--------------------------------------+
s06: Subagent — spawn sub-agents with fresh messages[] for context isolation.
Parent Agent Subagent
+------------------+ +------------------+
| messages=[...] | | messages=[task] | <-- fresh
| | dispatch | |
| tool: task | ---------------> | own while loop |
| prompt="..." | | bash/read/... |
| | summary only | (max 30 turns) |
| result = "..." | <--------------- | return last text |
+------------------+ +------------------+
^ |
| intermediate results DISCARDED |
+--------------------------------------+

测试 Prompt

派一个子代理检查当前目录有哪些 Markdown 文件,主代理只需要返回子代理的总结。
派一个子代理检查当前目录有哪些 Markdown 文件,主代理只需要返回子代理的总结。

观察重点:主代理应该调用 task,探索过程留在子代理上下文里,主对话只拿到最终摘要。

s07:Skill Loading —— 知识按需加载

新人不可能第一天就背完整本公司手册,更不该每次开会都把所有资料搬上桌。s07 教他先看目录,需要某项专业知识时再去翻对应手册,这样脑子里只装当下用得上的内容。

本章目标

如果一开始把所有专业知识都塞进 system prompt,上下文会很快变大,而且很多知识根本用不上。

s07 加入技能系统:system prompt 里只放技能目录,模型需要时再调用 load_skill 读取完整内容。

新增能力

本章沿用前面的 todo_write 和 task,只新增“技能按需加载”。

  • 扫描 skills/ 目录。
  • 从 SKILL.md 读取 name 和 description。
  • system prompt 只展示技能列表。
  • load_skill(name) 按需返回完整技能文本。

为什么这一步重要

技能系统解决的是“知识不要预加载”的问题。

目录常驻很便宜,全文按需加载才可控。后续 MCP、插件、团队能力,本质上也会用类似的“发现 -> 按需展开”模式。

参考实现提醒

Skill Loading 的重点是“两级加载”:

  • system prompt 里只放技能名和一句话描述,避免启动时塞满上下文。
  • 真的需要某个技能时,再通过 load_skill 把完整内容作为 tool result 注入。
  • 技能文件是知识数据,不是越权指令;真实系统还要防 prompt injection。

新增函数速览

  • scanSkills(dir):扫描 skills/ 的直接子目录,读取其中的 SKILL.md frontmatter 和正文。
  • listSkills():把已发现的 skill 压成一段简短清单,注入 system prompt 让模型知道有哪些技能。
  • loadSkill({ name }):按名称读取某个 skill 的完整内容,并通过工具结果按需交给模型。
  • agentLoop(messages, confirm):在 s06 基础上多暴露一个 load_skill 工具。

流程图

s07 Skill Loading 架构图

启动时
|
v
扫描 skills/
|
v
system prompt 只放目录
|
| 需要某个技能
v
tool_call: load_skill(name)
|
v
完整技能内容作为 tool_result 回到模型
启动时
|
v
扫描 skills/
|
v
system prompt 只放目录
|
| 需要某个技能
v
tool_call: load_skill(name)
|
v
完整技能内容作为 tool_result 回到模型

测试 Prompt

先查看当前有哪些可用技能,如果有适合仓库检查的技能,就加载它并用它检查 README。
先查看当前有哪些可用技能,如果有适合仓库检查的技能,就加载它并用它检查 README。

观察重点:模型应该先从 skills catalog 判断是否需要技能,再调用 load_skill 读取完整内容。

s08:Context Compact —— 给长会话腾空间

新人连续工作几个小时后,桌面会堆满便签、日志和临时资料。s08 教他整理桌面:重要结论留下,超长输出保存到文件,旧过程压缩成摘要,让后面的工作还能继续。

本章目标

agent 一直跑,messages 一定会越来越长。上下文满了以后,不处理就会失败。

s08 加入分层压缩流程:先用不调用模型的简单规则缩短历史,再必要时让模型总结旧历史。

新增能力

本章沿用前面的计划、子代理和技能加载,只新增上下文压缩。

  • toolResultBudget:如果工具结果太大,就把完整内容写入文件,并在 messages 里只保留文件路径和预览。
  • snipCompact:消息太多时省略中间较旧的部分。
  • microCompact:较早的工具结果缩成一句简短说明。
  • compact 工具:模型可主动请求压缩。
  • 应急压缩:API 报上下文过长时,先总结旧历史再重试。

为什么这一步重要

压缩不是删除历史,而是保住“下一步需要的连续性”。

不调用模型的简单规则先跑,LLM 摘要后跑。这样可以减少模型输入长度,也让长会话不容易失败。

参考实现提醒

压缩不是一次性“总结一下”,而是一组分层策略:

  • 大工具结果优先写入文件保存,messages 里只留下文件路径和一小段预览,避免上下文变得过大。
  • 旧消息可以省略或总结,但当前任务、最新计划和未完成状态要保留。
  • API 报上下文太长时,要先做应急压缩再重试,而不是直接放弃会话。
  • 本教程把 CONTEXT_LIMIT 设成 12000,方便在命令行里观察自动压缩;真实项目可以按模型上下文窗口调大。
  • 本章的 estimateSize() 和 toolResultBudget() 使用 JavaScript 字符串长度做近似,不是真实 byte 或 token;生产系统应使用模型 tokenizer 或 Provider 返回的 usage。
  • 摘要输入超过 80,000 字符时,prepareHistoryForSummary() 会保留稳定头部和最新尾部。只保留最旧前缀会丢掉当前任务,只保留最新尾部又可能丢掉用户最初的硬约束。

新增函数速览

  • snipCompact(messages):删除中间的旧消息,只保留开头和最近对话。
  • microCompact(messages):压短较早的 tool result,保留“这个工具曾经跑过”的线索。
  • toolResultBudget(messages):当工具输出太大时,把完整内容写入文件,只在上下文里保留文件路径和预览。
  • compactHistory(messages):调用模型总结历史,把长对话压成一条可继续工作的摘要。
  • reactiveCompact(messages):当模型提示上下文过长时,立即触发一次总结式压缩。

流程图

s08 Context Compact 总览图|588

s08 三层压缩策略图|580

s08 工具结果大小控制图

s08 旧工具结果精简流程图

s08 自动压缩流程图

messages 变大
|
v
大工具结果写入文件保存
|
v
省略中间较旧消息
|
v
旧工具结果缩成一句说明
|
v
仍然太大?
|
+-- 否 --> 调 LLM
|
+-- 是 --> 摘要旧历史 --> 调 LLM
messages 变大
|
v
大工具结果写入文件保存
|
v
省略中间较旧消息
|
v
旧工具结果缩成一句说明
|
v
仍然太大?
|
+-- 否 --> 调 LLM
|
+-- 是 --> 摘要旧历史 --> 调 LLM

测试 Prompt

在同一个 CLI 会话里依次输入下面三条 Prompt:

读取 code-agent-course/helper.js 的前 200 行,然后主动 compact,再总结你仍然记得哪些关键信息。
不要调用任何工具。请输出一段很长的中文说明,主题是“agent loop 为什么需要上下文压缩”,至少 9000 个汉字,分成 30 段,每段都以 MEMORY_BLOCK 开头,最后写 LONG_CONTEXT_DONE。
不要调用 bash。请告诉我:你是否还能记得上一轮的主题,以及当前对话为什么需要上下文压缩。
读取 code-agent-course/helper.js 的前 200 行,然后主动 compact,再总结你仍然记得哪些关键信息。
不要调用任何工具。请输出一段很长的中文说明,主题是“agent loop 为什么需要上下文压缩”,至少 9000 个汉字,分成 30 段,每段都以 MEMORY_BLOCK 开头,最后写 LONG_CONTEXT_DONE。
不要调用 bash。请告诉我:你是否还能记得上一轮的主题,以及当前对话为什么需要上下文压缩。

观察重点:大工具结果进入消息后,压缩逻辑应该保留后续回答需要的工作记忆。

s09:Memory —— 只保存下次还要用的信息

新人工作久了会遇到两类信息:一类是当前任务里的临时过程,比如命令输出、探索路径、一次性结论;另一类是以后还会反复用到的事实,比如用户偏好、项目约定、长期规则。

s08 的 compact 负责让当前会话继续跑下去,s09 的 memory 负责把“下次还要用”的信息保存下来。

本章目标

压缩可以缩短当前会话历史,但压缩后的摘要不适合保存所有长期知识。

s09 加入 memory 层:

  • 长期记忆保存到 .memory/ 文件夹。
  • MEMORY.md 是记忆索引,只放名字和一句话描述。
  • 每轮请求开始时,只挑和当前问题相关的记忆临时放进请求里。
  • 每轮请求结束后,从本轮对话里提取新的长期记忆。
  • 记忆文件太多时,再做整理合并。

为什么这一步重要

memory 不是“把聊天记录永久保存”。

只有下次很难从当前代码和当前对话重新推导出来的信息,才值得保存。例如:

  • 用户偏好:喜欢简洁教程、喜欢先看代码再看解释。
  • 项目约定:这个项目统一使用 ES Module。
  • 长期规则:文档里不要使用难懂黑话。
  • 反馈结论:某类 prompt 更适合这个教程。

临时命令、一次性输出、当前任务的中间推理,不应该进入 memory。否则 memory 会变成另一个越来越大的上下文。

新增函数速览

  • writeMemoryFile(name, type, description, body):把一条长期记忆保存成 Markdown 文件。
  • listMemoryFiles() / readMemoryIndex():读取已有记忆和索引,给后续选择与注入使用。
  • selectRelevantMemories(messages, maxItems):让模型从记忆索引里挑出当前任务相关的记忆。
  • loadMemories(messages):把选中的记忆正文合并成一段上下文,只在本轮请求时注入。
  • extractMemories(messages):任务结束后调用模型,判断哪些信息值得跨会话保存。
  • consolidateMemories():定期整理记忆,减少重复和过期内容。

流程图

s09 Memory 总览图|669

s09 Memory 子系统图|675

用户输入
|
v
读取 MEMORY.md 索引
|
v
选择相关记忆
|
v
临时放进本次请求
|
v
agent loop
|
v
回合结束:提取新的长期记忆
|
v
写入 .memory/*.md,并重建 MEMORY.md
用户输入
|
v
读取 MEMORY.md 索引
|
v
选择相关记忆
|
v
临时放进本次请求
|
v
agent loop
|
v
回合结束:提取新的长期记忆
|
v
写入 .memory/*.md,并重建 MEMORY.md
.memory/
MEMORY.md # 索引:每条记忆一行,便宜、常驻
tutorial-style.md # 单条记忆:Markdown + frontmatter
project-facts.md
每一轮 agent_loop:
1. 读取 MEMORY.md 索引
2. 选择和当前任务相关的记忆
3. 把记忆正文临时注入本轮请求
4. 继续沿用 s08 的上下文压缩
5. 回合结束后,从原始消息里提取新记忆
.memory/
MEMORY.md # 索引:每条记忆一行,便宜、常驻
tutorial-style.md # 单条记忆:Markdown + frontmatter
project-facts.md
每一轮 agent_loop:
1. 读取 MEMORY.md 索引
2. 选择和当前任务相关的记忆
3. 把记忆正文临时注入本轮请求
4. 继续沿用 s08 的上下文压缩
5. 回合结束后,从原始消息里提取新记忆

测试 Prompt

第一次运行:让 agent 保存一个长期偏好。

remember: 我写教程时喜欢先给最小可运行代码,再解释原理。请记住这个长期偏好,并用一句话回复。
remember: 我写教程时喜欢先给最小可运行代码,再解释原理。请记住这个长期偏好,并用一句话回复。

观察重点:

  • 命令行里应该看到类似 [Memory: extracted 1 new memories]。
  • .memory/ 下应该出现新的 .md 记忆文件。
  • .memory/MEMORY.md 应该出现一条索引。

输入 q 退出,重新运行 node code-agent-course/s09-memory.js,再验证跨会话记忆:

下次帮我写教程时,我喜欢什么顺序?请根据 memory 回答,不要读取文件。
下次帮我写教程时,我喜欢什么顺序?请根据 memory 回答,不要读取文件。

观察重点:模型应该能说出“先给最小可运行代码,再解释原理”。

如果想直接查看文件,也可以再输入:

用 glob 查找 .memory/*.md,然后总结这些 memory 文件保存了什么。
用 glob 查找 .memory/*.md,然后总结这些 memory 文件保存了什么。

s10:System Prompt —— 从固定文本变成装配流水线

新人每天上班前需要一份任务说明,但不同场景需要不同提醒:有时要强调安全,有时要带上记忆,有时要加载技能。s10 把这份说明书变成装配线,每轮按当前情况组装给他看。

本章目标

system prompt 不应该是一大段永远不变的字符串。

s10 把 prompt 拆成多个 section,每轮根据当前运行时状态装配。

新增能力

本章不减少已有能力,只把已有的工具、技能、压缩和记忆说明改成按段装配。

  • assembleSystemPrompt(context):按 section 拼 prompt。
  • getSystemPrompt(context):相同 context 走缓存。
  • buildContext():收集工具、技能、记忆等运行时状态。
  • 工具执行后重新计算 prompt。

为什么这一步重要

prompt 是输入流水线,不是固定文案。

当工具、技能、记忆变化时,模型看到的系统信息也应该变化。后面的 MCP 动态工具池就是这个思想的延伸。

参考实现提醒

System Prompt 不是一段越写越长的常量,而是运行时输入流水线:

  • 身份、工具、技能目录、记忆、MCP 状态都应该按当前状态装配。
  • prompt section 要有稳定顺序,避免同样状态下模型看到的规则忽前忽后。
  • 缓存 prompt 可以省成本,但状态变化后必须失效,否则模型会看见旧世界。

新增函数速览

  • assembleSystemPrompt(context):按固定顺序把身份、工具、技能、压缩规则和记忆拼成最终 system prompt。
  • getSystemPrompt(context):按上下文 key 缓存 system prompt,避免每一轮重复拼装。
  • resetSystemPromptCache():测试或配置变化时清空 prompt 缓存。
  • buildContext():读取当前工具名、skill 列表和 memory 索引,生成 prompt 装配所需的 context;工作目录由 identity section 直接读取。

流程图

s10 System Prompt 装配图|664

运行时状态
|
+--> tools
+--> skills
+--> memory index
+--> compact policy
|
v
assembleSystemPrompt(context)
|
v
system prompt
|
v
call LLM
运行时状态
|
+--> tools
+--> skills
+--> memory index
+--> compact policy
|
v
assembleSystemPrompt(context)
|
v
system prompt
|
v
call LLM

测试 Prompt

先列出当前可用工具和技能,再创建一个小文件 prompt-check.txt。
先列出当前可用工具和技能,再创建一个小文件 prompt-check.txt。

观察重点:system prompt 应该按当前工具、技能、记忆等运行时状态组装,而不是固定字符串。

s11:Error Recovery —— 失败进入恢复分支

真实工作里,新人会遇到接口限流、服务过载、输出写到一半和材料太长。s11 不再让 agent 遇错就退出,而是先判断错误类型,再选择等待重试、续写、压缩或停止。

本章目标

s10 已经能动态组装 system prompt,但一次 LLM 请求失败时,主循环仍缺少恢复策略。

s11 保留前面的工具、计划、子代理、技能、压缩、记忆和动态 prompt,只给 LLM 调用增加四条恢复路径:

  • 429 限流:等待后重试。
  • 529 服务过载:等待后重试,连续发生时可切换备用模型。
  • finish_reason: "length":提高输出上限,仍被截断时继续生成。
  • 上下文过长:先做一次应急压缩,再重试当前请求。

为什么这一步重要

重试不是简单地再调用一次接口。限流需要等待,输出截断需要续写,上下文超限需要先腾出空间,不可恢复错误则应该停止。RecoveryState 把这些状态集中保存,主循环才知道自己是在正常执行还是恢复。

新增函数速览

  • RecoveryState:记录是否提高过输出上限、续写次数、连续 529 次数、是否做过应急压缩,以及当前备用模型。
  • retryDelay(attempt, retryAfter):计算指数退避时间;第二个参数是已经换算成毫秒的等待时间。
  • withRetry(fn, state):包住一次 LLM 请求,只对 429 和 529 这类瞬态错误退避重试。
  • setSleepFn(fn):测试入口,用立即返回的函数替换真实等待。

reactiveCompact(messages) 来自 s08,本章只是把它接到“上下文过长”的恢复分支,不是重新实现一套压缩。

流程图

s11 Error Recovery 架构图|666

call LLM
|
+-- 成功 ------------------------> 正常处理
|
+-- 429 / 529 ------------------> 等待 --> 重试
|
+-- finish_reason = length -----> 提高上限 / 续写
|
+-- prompt too long ------------> reactiveCompact --> 重试一次
|
+-- 其他错误 --------------------> 返回 [Error]
call LLM
|
+-- 成功 ------------------------> 正常处理
|
+-- 429 / 529 ------------------> 等待 --> 重试
|
+-- finish_reason = length -----> 提高上限 / 续写
|
+-- prompt too long ------------> reactiveCompact --> 重试一次
|
+-- 其他错误 --------------------> 返回 [Error]

测试 Prompt

请输出一份尽可能详细的 80 步 agent 开发计划;如果输出被截断,请继续,不要从头重复。
请输出一份尽可能详细的 80 步 agent 开发计划;如果输出被截断,请继续,不要从头重复。

自然 prompt 不一定触发 429 或 529,但可能触发 finish_reason: "length"。测试文件通过模拟响应稳定覆盖限流、过载、截断和上下文超限分支。

s12:Task System —— 把目标持久化成任务图

Todo 像当前会话里的便签;跨会话、跨阶段协作时,则需要一块能保存下来的任务板。s12 在 todo_write 之外增加 task system,让任务有独立文件、依赖关系、状态和负责人。

本章目标

s05 的 todo 负责“这次对话接下来做什么”,只存在于当前 Node 进程。s12 的 task 负责“整个项目有哪些工作、先后关系是什么”,每个任务保存到 .tasks/,进程重启后仍可读取。

本章保留 s11 的全部能力,只新增五个任务工具:

  • create_task:创建任务。
  • list_tasks:查看任务板。
  • get_task:读取一个任务的完整信息。
  • claim_task:认领依赖已完成的任务。
  • complete_task:完成任务,并报告刚被解锁的下游任务。

教学版边界

这里的“持久化”只表示进程重启后还能从文件恢复,不表示多进程并发安全:

  • claimTask() 是读取、判断、再写回,两个进程同时认领时可能都以为自己成功;
  • JSON 文件没有数据库事务、版本号、文件锁或 lease;
  • Date.now() + 进程内序号 适合教学,不是全局唯一 ID;
  • 生产系统应使用唯一约束、条件更新或 compare-and-swap 抢占任务,并保存事件日志来审计每次状态迁移。

所以本章证明的是“任务图如何建模和恢复”,不是“分布式调度器已经完成”。

新增函数速览

  • setTasksDir(dir):测试入口,把任务目录临时切换到测试目录。
  • createTask(subject, description, blockedBy):创建 pending 任务并写入 JSON 文件。
  • listTasks():读取并按文件名排序所有任务。
  • canStart(id):检查 blockedBy 中的任务是否全部存在且已完成。
  • claimTask(id, owner):把可开始的任务从 pending 改为 in_progress,并记录 owner。
  • completeTask(id):把进行中的任务改为 completed,再找出刚被解锁的下游任务。
  • runCreateTask() / runListTasks() / runGetTask():把底层任务函数转换成适合返回给模型的文本。

流程图

s12 Task System 总览图

s12 Task DAG 依赖图

create_task
|
v
.tasks/{task_id}.json
|
v
pending
|
| canStart:blockedBy 全部 completed?
v
claim_task
|
v
in_progress + owner
|
v
complete_task
|
v
completed + 解锁下游任务
create_task
|
v
.tasks/{task_id}.json
|
v
pending
|
| canStart:blockedBy 全部 completed?
v
claim_task
|
v
in_progress + owner
|
v
complete_task
|
v
completed + 解锁下游任务

测试 Prompt

创建三个持久任务:数据库 schema、依赖 schema 的 API、依赖 API 的测试;列出任务,认领并完成第一个任务,再说明哪个任务被解锁。
创建三个持久任务:数据库 schema、依赖 schema 的 API、依赖 API 的测试;列出任务,认领并完成第一个任务,再说明哪个任务被解锁。

观察 .tasks/:应出现三个 JSON 文件;第二、三个任务的 blockedBy 应保存前置任务 id。第一个任务完成后,API 任务应变为可认领,但测试任务仍被阻塞。

s13:Background Tasks —— 慢操作放到后台

新人去打印一百页资料时,不该站在打印机旁什么都不做。s13 让 agent 把慢命令交给后台子进程,先拿到任务 id,继续处理别的工作,完成后再接收通知。

本章目标

s12 已经能保存任务图,但工具仍是同步执行:一次 bash 不结束,agent loop 就不能继续。

s13 保留前面的全部能力,只改变慢工具的执行方式:

  1. 模型发出带 run_in_background: true 的 bash 调用,或命令命中慢操作关键词。
  2. 系统启动异步子进程,立即返回后台任务 id。
  3. 主 loop 把占位 tool_result 回给模型,继续处理其他工具。
  4. 子进程完成后,结果以新的 <task_notification> 消息注入对话。

为什么这一步重要

持久 task 和后台运行槽位是两件事:

  • Task System 记录“要完成什么、依赖谁、由谁负责”。
  • Background Task 记录“这条命令是否还在运行、结果是什么”。

本章的后台任务只保存在 backgroundTasks 内存 Map 中,不会写入 .tasks/,进程退出后也不会恢复。

新增函数速览

  • isSlowOperation(name, args):只对 bash 命令做关键词判断,例如 install、build、test。
  • shouldRunBackground(name, args):模型显式传 run_in_background 时优先,否则使用慢命令启发式。
  • startBackgroundTask(name, args, run):创建 bg_XXXX 记录,异步启动 run,立即返回任务 id。
  • runBashInBackground(command):使用 spawn() 启动异步 shell 子进程并收集 stdout / stderr。
  • collectBackgroundResults():取出已完成任务,生成 <task_notification>,并从 Map 删除任务。
  • injectBackgroundNotifications(messages):把完成通知作为新的 user 消息加入对话。
  • waitForBackgroundTick():等待 25ms,让超快命令有机会在本轮结束前完成。
  • resetBackgroundTasks():测试入口,清空后台任务状态。

流程图

s13 Background Tasks 架构图|690

LLM: bash(..., run_in_background=true)
|
v
spawn 异步子进程
|
+------> 立即返回 bg_0001
| |
| v
| 主 loop 继续工作
|
v
子进程完成
|
v
<task_notification> 注入 messages
|
v
LLM 继续
LLM: bash(..., run_in_background=true)
|
v
spawn 异步子进程
|
+------> 立即返回 bg_0001
| |
| v
| 主 loop 继续工作
|
v
子进程完成
|
v
<task_notification> 注入 messages
|
v
LLM 继续

测试 Prompt

用 bash 后台运行 `sleep 0.01 && echo background done`,同时继续读取 README.md。
用 bash 后台运行 `sleep 0.01 && echo background done`,同时继续读取 README.md。

预期日志顺序大致如下,具体 turn 数取决于模型一次返回多少个 tool call:

Turn 1:
LLM → bash "sleep 0.01 && echo background done" (run_in_background=true)
[background] dispatched bg_0001: sleep 0.01 && echo background done
→ start_background_task → bg_0001
→ tool_result: "[Background task bg_0001 started]..."
LLM → read_file "README.md" (fast, sync)
→ tool_result: "# build-your-agent..."
Turn 2:
→ collect: bg_0001 done! inject <task_notification>
LLM → 同时看到 README 内容和后台完成通知
Turn 1:
LLM → bash "sleep 0.01 && echo background done" (run_in_background=true)
[background] dispatched bg_0001: sleep 0.01 && echo background done
→ start_background_task → bg_0001
→ tool_result: "[Background task bg_0001 started]..."
LLM → read_file "README.md" (fast, sync)
→ tool_result: "# build-your-agent..."
Turn 2:
→ collect: bg_0001 done! inject <task_notification>
LLM → 同时看到 README 内容和后台完成通知

s14:Cron Scheduler —— 时间也能触发 agent

有些任务不该一直等用户开口,比如每天早上检查日报、每隔几分钟看一次任务状态。s14 给 agent 装一个闹钟:时间到了,系统自动把预设 prompt 送进 agent loop。

这一章对齐 Python 版的核心设计:scheduler 负责发现到点任务,queue processor 负责在 agent 空闲时自动交付任务。

本章目标

有了 s13 的后台任务以后,agent 已经能“慢慢跑”。但它仍然需要用户输入来启动。

s14 新增时间触发入口:

注意:当前实现还是进程内调度器,不是操作系统的 cron。只要 node s14-cron-scheduler.js 这个进程活着,它就会自动执行到点任务。

新增能力

  • schedule_cron:注册一个定时任务。
  • list_crons:列出当前所有定时任务。
  • cancel_cron:取消一个定时任务。
  • cronTick():每秒检查哪些任务到点。
  • cronQueue:暂存已经到点、等待交付的任务。
  • processCronQueue():agent 空闲时自动消费队列,不等用户下一次输入。
  • durable job:把任务定义写入 .scheduled_tasks.json,下次启动时恢复。

为什么这一步重要

s13 解决的是“工具已经开始后,怎样不阻塞主循环”;s14 解决的是“没有用户输入时,谁来启动一轮工作”。scheduler 只判断时间并入队,queue processor 负责在 agent 空闲时交付,原来的 agent loop 仍负责调用模型和工具。

新增函数速览

  • cronMatches(expr, date):判断某个时间是否命中五段式 cron 表达式;这是纯工具函数,放在 helper.js。
  • validateCron(expr):在保存前检查 cron 表达式;这是纯工具函数,放在 helper.js。
  • setCronStorePath(path) / resetCron():测试入口,替换持久化文件并清空进程内状态。
  • loadDurableJobs():启动时从 .scheduled_tasks.json 恢复合法的 durable job。
  • scheduleJob(cron, prompt, recurring, durable):创建定时任务,并决定是否写入 .scheduled_tasks.json。
  • cancelJob(id) / listCronJobs():取消或列出已注册任务。
  • cronTick(now):每秒扫描任务,把到点任务放入 cronQueue。
  • hasCronQueue():判断队列里是否有等待交付的任务。
  • consumeCronQueue():一次性取出队列里的到点任务。
  • processCronQueue():当 agent 空闲且队列里有任务时,自动启动一轮 agent loop。
  • runInteractiveAgentTurn():用户输入也要等待当前定时任务结束,避免两个 agent loop 同时修改共享状态。
  • runScheduleCron() / runListCrons() / runCancelCron():把 cron 能力包装成模型可调用的工具 handler。

流程图

s14 Cron Scheduler 架构图|682

用户要求 schedule_cron
|
v
scheduleJob 保存 CronJob
|
v
cronTick 每秒检查
|
v
到点任务进入 cronQueue
|
v
processCronQueue 自动交付
|
v
agentLoop 注入 [Scheduled] prompt
|
v
LLM 调用工具并输出结果
用户要求 schedule_cron
|
v
scheduleJob 保存 CronJob
|
v
cronTick 每秒检查
|
v
到点任务进入 cronQueue
|
v
processCronQueue 自动交付
|
v
agentLoop 注入 [Scheduled] prompt
|
v
LLM 调用工具并输出结果

测试 Prompt

Schedule a task to print the current date every 1 minutes
Schedule a task to print the current date every 1 minutes

观察重点:注册成功后,即使你不再输入,终端也会继续出现 [cron fire]、[queue processor]、[inject cron] 和 date 的执行结果。

s15:Agent Teams —— 有身份的队友和文件邮箱

当任务变大,一个 agent 不够用了。s15 开始搭一个小团队:每个队友有名字、角色、独立上下文和文件收件箱;lead 不必等待队友执行完,结果会通过邮箱稍后返回。

本章目标

s06 的 task 是一次性子代理:给它一个任务,它做完后只返回摘要。s15 的 teammate 有名字和角色,可以运行多轮,并通过邮箱向 lead 汇报。

这里要区分两个生命周期:.mailboxes/*.jsonl 会保留到被读取;teammate 本身仍是最多运行十轮的后台任务,给出最终文本后就退出。到 s16 才会加入短暂 idle loop,等待后续消息或关机请求。

这一章只新增团队通信层,前面章节的 todo、skill、memory、task、background task、cron queue processor 都继续保留。

新增能力

  • spawn_teammate:启动一个有名字和角色的队友。
  • send_message:把消息写入某个 agent 的文件收件箱。
  • check_inbox:lead 主动查看自己的收件箱。
  • .mailboxes/*.jsonl:每个 agent 一个收件箱文件,每一行是一条 JSON 消息。
  • 队友独立多轮子循环:队友可以调用自己的工具,并把结果发回 lead。

为什么这一步重要

团队不是“多调用几次 LLM”。真正关键的是通信结构变了:

  • 子代理适合短任务,父 agent 只需要最终摘要。
  • 队友适合长期协作,lead 需要知道谁说了什么、谁正在负责什么。
  • 文件邮箱让通信可检查,调试时可以直接打开 .mailboxes/lead.jsonl 看消息。

参考实现提醒

Agent Teams 的核心是“可检查的通信”,不是神秘多智能体:

  • 文件邮箱比内存消息更容易调试,因为每条消息都能在磁盘上看到。
  • lead 和 teammate 都应该有身份,否则日志和责任边界会混在一起。
  • 队友也要走权限门,不能因为换了执行者就绕过主系统规则。
  • 本章 JS 版用未 await 的 Promise 启动队友子循环。JavaScript 仍是单线程,但 LLM 和文件操作等待期间,lead 与 teammate 的异步流程可以交替推进。
  • 本章新增日志用颜色区分:[teammate] 是队友生命周期,[bus] 是邮箱消息流。

一个完整的团队流程大概长这样:

新增函数速览

  • sendMessage(from, to, content, type):把一条消息追加写入 to.jsonl,例如发给 lead 就写 .mailboxes/lead.jsonl。
  • readInbox(agent):读取某个 agent 的收件箱,读完就删除文件,表示消息已经被消费。
  • spawnTeammate(name, role, prompt, confirm):启动一个后台队友,立即返回;队友稍后把总结写进 lead 的邮箱。
  • runSpawnTeammate、runSendMessage、runCheckInbox:把 LLM 的 tool call 参数转成具体函数调用。
  • runInteractiveAgentTurn:沿用 s14 的保护逻辑,避免用户输入回合和 cron 自动回合同一时间抢 agentLoop。

流程图

s15 Agent Teams 总览图|646

s15 Team Topology 拓扑图|697

lead agent
|
| spawn_teammate(name, role, prompt)
v
teammate messages[] .mailboxes/
| ^
| 独立调用 LLM + tools |
v |
send_message(to="lead") ----------+
|
v
lead 下一轮 readInbox("lead")
|
v
[Inbox] From alice: ...
lead agent
|
| spawn_teammate(name, role, prompt)
v
teammate messages[] .mailboxes/
| ^
| 独立调用 LLM + tools |
v |
send_message(to="lead") ----------+
|
v
lead 下一轮 readInbox("lead")
|
v
[Inbox] From alice: ...

测试 Prompt

试试这些 prompt:

Spawn alice as a backend developer. Ask her to create a file called schema.sql with a users table.
Check your inbox for alice's result.
Spawn alice as a backend developer. Ask her to create a file called schema.sql with a users table.
Check your inbox for alice's result.

观察重点:第一句应该触发 spawn_teammate,并看到彩色 [teammate] 日志;Alice 完成后会通过 [bus] alice → lead 写入 .mailboxes/lead.jsonl。第二句应该触发 check_inbox 或在下一轮读取 inbox,把 Alice 的结果带回主对话。

如果运行后看到 .mailboxes/lead.jsonl 里有 alice -> lead 的 JSONL 消息,这是正常结果:说明 Alice 在 lead 回复后才完成,消息还没被下一轮消费。

s16:Team Protocols —— 协作用 request-response

队友变多以后,光靠“随口聊天”很快会乱:谁提出了请求,谁批准了计划,哪条回复对应哪件事,都需要一个可追踪的格式。s16 在 s15 的文件邮箱上加一层团队协议:每个请求都有 request_id,每个响应都带着同一个 request_id 回来。

本章目标

s15 解决了“队友怎么通信”:用 .mailboxes/*.jsonl 传消息。s16 解决的是“消息怎么对上号”:用 request-response 协议把请求和响应关联起来。

这一章保留 s15 的后台队友、cron queue processor 和文件邮箱,只新增团队协议层。日志上,[bus] / [teammate] 继续表示队友生命周期和邮箱消息流,s16 新增的 [protocol] 用来突出 request-response 协议事件。

新增能力

  • pendingRequests:保存还没完成的协议请求。
  • request_id:把请求和响应绑定在一起。
  • matchResponse:收到响应后推进状态机。
  • consumeLeadInbox:统一读取 lead inbox,并先路由协议响应。
  • sendMessage(..., metadata):邮箱消息新增结构化 metadata,用来保存 request_id 和审批结果。
  • request_shutdown:lead 请求队友优雅退出。
  • request_plan:lead 请求队友提交计划。
  • review_plan:lead 审批队友提交的计划。
  • submit_plan:队友侧工具,向 lead 提交计划审批请求。

为什么这一步重要

协议让团队协作从“聊天记录”变成“可追踪事务”。

  • 没有 request_id,多个队友同时发计划时,lead 很难知道自己批准的是哪一个。
  • 没有状态机,重复响应、错类型响应、过期响应都会混在一起。
  • 有了统一的 request-response 结构,后面可以继续扩展任务认领、交付确认、冲突协商。

参考实现提醒

  • request_id 是协作里的锚点,没有它就很难匹配异步回复。
  • response 类型要和 request 类型对应,避免把 shutdown 回复当成 plan 回复。
  • 队友协议工具 submit_plan 放在队友自己的 tool list 里,不放到 lead 的主工具列表。
  • lead-only 协议工具要从普通 subagent 的工具列表里排除,避免子代理绕过 lead 发团队命令。
  • 本章 JS 版继续沿用 s15 的后台 Promise 队友模型,但多了短暂 idle loop。
  • idle loop 不是永久常驻线程;它只是为了教程演示,让队友在完成后还能处理一段时间的后续消息和 shutdown。
  • submit_plan 目前只是协议消息,不是强制执行门。队友提交计划后,代码不会自动冻结所有其他工具;生产实现还要增加“等待审批”状态,并在审批前拒绝写文件或执行命令。
  • Lead 不应该用 sleep && cat file 反复轮询 teammate 的结果;更清晰的方式是 check_inbox 等 teammate 回报,再验证文件。

新增函数速览

  • newRequestId():内部函数,生成 req_0000 这种进程内递增 ID。
  • resetProtocol():测试入口,清空 pending request 并重置序号。
  • matchResponse(responseType, requestId, approve):根据响应类型和 request_id 更新 pending 状态。
  • consumeLeadInbox(routeProtocol):读取 lead 收件箱,先把协议响应路由到状态机,再返回消息。
  • teammateSubmitPlan(fromName, plan):队友调用 submit_plan 时,创建 plan_approval pending request,并写入 lead inbox。
  • teammate idle loop:队友没有新工具要调用时,短暂等待 inbox;收到普通消息就继续跑,收到 shutdown 就回复并退出。
  • runRequestShutdown({ teammate }):lead 写入 shutdown_request,等待队友回 shutdown_response。
  • runRequestPlan({ teammate, task }):lead 让队友先提交计划;真正的 pending request 会在队友调用 submit_plan 时创建。
  • runReviewPlan({ request_id, approve, feedback }):lead 审批计划,并把 plan_approval_response 发回队友。

流程图

s16 Team Protocols 架构图|662

Lead Mailbox Teammate
| | |
| request_plan | |
|----------------------------->| lead → bob: please plan |
| |------------------------------>|
| | | submit_plan
| |<------------------------------|
|<-----------------------------| bob → lead: plan_approval_request(req_0000)
| pendingRequests[req_0000] = pending |
| |
| review_plan(req_0000, approve=true) |
|----------------------------->| lead → bob: plan_approval_response(req_0000)
| pendingRequests[req_0000] = approved |
Lead Mailbox Teammate
| | |
| request_plan | |
|----------------------------->| lead → bob: please plan |
| |------------------------------>|
| | | submit_plan
| |<------------------------------|
|<-----------------------------| bob → lead: plan_approval_request(req_0000)
| pendingRequests[req_0000] = pending |
| |
| review_plan(req_0000, approve=true) |
|----------------------------->| lead → bob: plan_approval_response(req_0000)
| pendingRequests[req_0000] = approved |

测试 Prompt

试试这个 prompt:

Spawn bob as a backend developer and ask only for a plan to update README.md. When the plan arrives, approve it by request_id and check your inbox.
Spawn bob as a backend developer and ask only for a plan to update README.md. When the plan arrives, approve it by request_id and check your inbox.

观察重点:Bob 应该通过 submit_plan 发出 plan_approval_request,lead 用同一个 request_id 调用 review_plan,最后 pendingRequests 里的状态从 pending 变成 approved。本 prompt 不要求 Bob 修改文件,因为本章还没有实现强制审批门。

也可以单独测试关机握手:

Spawn alice as a developer, then use request_shutdown for alice and check your inbox.
Spawn alice as a developer, then use request_shutdown for alice and check your inbox.

观察重点:lead 发 shutdown_request,Alice 回 shutdown_response,matchResponse 把对应请求推进到 approved。

s17:MCP Plugin —— 外部工具进入统一控制面

一个 coding agent 不可能把搜索、数据库、浏览器、部署平台和公司内部系统全写进主文件。s17 用一个可运行的 mock MCP hub 演示:外部工具被发现以后,怎样和内置工具共用同一条 agent loop。

本章目标

s16 已经有动态 prompt、权限 hook、任务、后台执行、团队和协议。s17 保留这些能力,只增加一条外部工具接入路径:

  1. 模型调用 connect_mcp 连接一个 mock server。
  2. hub 返回该 server 暴露的工具定义。
  3. 工具名被改成 mcp__server__tool,避免和内置工具重名。
  4. 下一轮模型请求使用更新后的工具池和 system prompt。
  5. 模型调用 MCP 工具时,仍走权限检查、handler 分发和 tool_call_id 回灌。

为什么这一步重要

MCP 的价值不是“多几个工具”,而是把第三方能力转换成 agent 已经认识的工具 schema 和 handler。工具来源可以不同,但模型选择、权限判断、执行和结果回灌不需要另写一条 loop。

教学版边界

本章没有实现真正的 MCP transport。createMockMcpHub() 没有 JSON-RPC initialize、tools/list、tools/call、stdio / HTTP transport、OAuth、断线重连或 list_changed 通知。

当前动态 MCP 工具只合并进 lead 的主 agentLoop();s06 subagent 和 s15 teammate 仍使用各自固定工具列表。若要让它们共享 MCP,需要在创建子 agent 时也调用 allTools(),再按角色过滤权限。

新增函数速览

  • prefixedMcpName(serverName, toolDef):生成 mcp__docs__search 形式的全局工具名。
  • createMockMcpHub():创建内置 docs、deploy server,并保存已连接 server。
  • resetMcp():测试入口,重建 hub 并清空 system prompt 缓存。
  • mcpToolSchemas():把已发现工具转成模型可接收的 function tool schema。
  • allTools():合并内置工具与当前已发现的 MCP 工具。
  • findMcpTool(name) / getHandler(name):按名字查找 MCP 定义,并把内置与外部 handler 统一到一个分发入口。
  • runConnectMcp({ name }):连接 mock server,打印新发现的工具,并让 prompt 缓存失效。

流程图

s17 MCP Plugin 架构图|697

内置 tools
|
+--------------------------+
| |
v |
connect_mcp("docs") |
| |
v |
mock hub 发现 search/version |
| |
v |
mcp__docs__search schema ------+
|
v
allTools() -> call LLM -> tool_calls
|
v
同一权限门 -> getHandler() -> tool result 回灌
内置 tools
|
+--------------------------+
| |
v |
connect_mcp("docs") |
| |
v |
mock hub 发现 search/version |
| |
v |
mcp__docs__search schema ------+
|
v
allTools() -> call LLM -> tool_calls
|
v
同一权限门 -> getHandler() -> tool result 回灌

测试 Prompt

连接 docs MCP 搜索 agent loop;再连接 deploy MCP 查询 api 状态,最后尝试 trigger api 并说明为什么被拦截。
连接 docs MCP 搜索 agent loop;再连接 deploy MCP 查询 api 状态,最后尝试 trigger api 并说明为什么被拦截。

观察重点:连接前不存在 mcp__docs__search;连接后下一轮工具池出现它。mcp__deploy__status 可以执行,带 destructive 标记的 mcp__deploy__trigger 应返回 Permission blocked。

s18:扩展阅读路线图

前面的章节已经搭出了一个可运行的 Code Agent。这一章暂时不再增加功能,而是先建立一张扩展阅读地图,记录后续值得继续研究的技术方向。

目前只展开第一个主题“从 Attention 到 KV Cache”。其他主题先保留标题和问题,等主线项目完成后再逐篇补充。

本章没有独立 JavaScript 文件。互动页面右侧继续展示 s17 的完整 Agent,左侧阅读扩展路线图。

已展开:从 Attention 到 KV Cache

在 s00 里,我们把完整的 messages 发给模型,然后等待它逐个生成新 Token。随着对话越来越长,你会发现请求通常也会越来越慢。

要理解其中的原因,需要把 HTTP 接口再向下拆一层:模型内部如何使用 Transformer 的注意力机制生成下一个 Token,以及 KV Cache 为什么能减少重复计算。

1. 文本先变成 Token

模型不能直接计算文字。输入文本会先经过 Tokenizer,变成一串 Token ID,再转换成向量:

"Code Agent 会使用工具"
|
v
[Token 1, Token 2, Token 3, ...]
|
v
[向量 1, 向量 2, 向量 3, ...]
"Code Agent 会使用工具"
|
v
[Token 1, Token 2, Token 3, ...]
|
v
[向量 1, 向量 2, 向量 3, ...]

这些向量进入多层 Transformer。每一层都会通过 Self-Attention,让当前 Token 从前面的 Token 中寻找与自己有关的信息。

2. Attention 在做什么

在每一层中,Token 向量会分别乘以三组权重,得到 Query、Key 和 Value:

Q = XWq
K = XWk
V = XWv
Q = XWq
K = XWk
V = XWv

可以先用“查资料”来理解它们:

  • Query:当前 Token 想查什么。
  • Key:每个历史 Token 的索引特征。
  • Value:每个历史 Token 真正携带的信息。

Attention 的核心计算可以简化为:

Attention(Q, K, V) = softmax(QK^T / sqrt(d))V
Attention(Q, K, V) = softmax(QK^T / sqrt(d))V

先用当前 Query 和所有 Key 计算相关程度,再按相关程度汇总对应的 Value。生成式模型还会加上 Causal Mask,保证当前位置只能看到自己和前面的 Token,不能偷看未来。

当前 Token 的 Q
|
v
和历史 Token 的 K 比较
|
v
得到注意力权重
|
v
按权重汇总历史 Token 的 V
当前 Token 的 Q
|
v
和历史 Token 的 K 比较
|
v
得到注意力权重
|
v
按权重汇总历史 Token 的 V

这就是 Transformer 从上下文中寻找信息的基本过程。

3. 大模型如何逐个生成 Token

一次文本生成通常可以分成两个阶段。

第一阶段叫 Prefill。模型一次处理 Prompt 中已有的全部 Token,并为每一层计算出对应的 Key 和 Value。

第二阶段叫 Decode。模型根据 Prompt 预测一个新 Token,再把这个 Token 放回序列,继续预测下一个:

Prompt -> Prefill -> 生成 Token A
|
Prompt + Token A ------+-> 生成 Token B
|
Prompt + Token A + Token B ---+-> 生成 Token C
Prompt -> Prefill -> 生成 Token A
|
Prompt + Token A ------+-> 生成 Token B
|
Prompt + Token A + Token B ---+-> 生成 Token C

如果没有缓存,每生成一个新 Token,都要重新处理前面整段没有变化的内容,并再次计算它们的 Key 和 Value。上下文越长,重复工作越多。

4. KV Cache 缓存了什么

KV Cache 的做法很直接:把每一层已经计算过的 Key 和 Value 保存起来。

生成下一个 Token 时,模型只计算新 Token 的 Q、K、V,然后把新的 K、V 追加到缓存。新的 Query 可以直接读取全部历史 K、V:

Prefill
-> 保存每层的 K1...Kn、V1...Vn
Decode 一个新 Token
-> 只计算新的 Q、K、V
-> 新 K、V 追加到 KV Cache
-> 新 Q 对全部缓存 K、V 做 Attention
-> 预测下一个 Token
Prefill
-> 保存每层的 K1...Kn、V1...Vn
Decode 一个新 Token
-> 只计算新的 Q、K、V
-> 新 K、V 追加到 KV Cache
-> 新 Q 对全部缓存 K、V 做 Attention
-> 预测下一个 Token

为什么不叫 QKV Cache?因为旧 Token 的 Query 已经在它们各自生成时使用过了。未来生成新 Token 时,需要的是“新 Query 查询历史 Key 和 Value”,旧 Query 不需要重复使用。

因此,KV Cache 没有改变模型的回答逻辑,它只是用显存换计算时间,让 Decode 阶段不必反复计算相同的历史内容。

5. 它为什么又快又占显存

KV Cache 的大小会随着上下文长度近似线性增长。可以用下面的公式建立一个粗略印象:

KV Cache 字节数
≈ 2 × 层数 × Token 数 × KV Head 数 × Head 维度 × 每个数值的字节数
^
K 和 V 两份
KV Cache 字节数
≈ 2 × 层数 × Token 数 × KV Head 数 × Head 维度 × 每个数值的字节数
^
K 和 V 两份

所以它有一个很明确的交换关系:

  • 缓存越多历史 Token,生成后续 Token 时越省计算。
  • 上下文越长、并发请求越多,KV Cache 占用的显存越大。
  • GQA、MQA、KV Cache 量化和 PagedAttention,都是继续降低或管理这部分成本的常见方向。

生产级推理框架不会只用一个不断扩大的数组保存缓存。例如 vLLM 的 PagedAttention 会把 KV Cache 切成块管理,思路类似操作系统的虚拟内存分页,从而减少显存碎片并提高并发能力。

6. KV Cache、Prompt Cache 和 messages 不是一回事

这三个概念经常同时出现,但它们处在不同层次:

概念保存什么主要作用通常由谁管理
messagesuser、assistant、tool 等消息表达对话语义Agent 应用
KV CacheTransformer 每一层的 K/V 张量加速当前生成过程模型推理服务
Prompt / Prefix Cache相同 Prompt 前缀对应的可复用计算结果减少跨请求的重复 PrefillProvider 或推理框架

在当前 s00 中,runChatCli() 保存的是 messages。每次调用 callLlm(),客户端仍然会把完整历史重新发送给 Provider。

Provider 会在一次文本生成过程中使用 KV Cache,但独立 HTTP 请求之间能否复用前缀,取决于它是否支持 Prompt Cache 或 Prefix Cache。客户端不能因为上一轮问过同样的内容,就默认下一轮一定会复用 KV Cache。

7. 这和 Code Agent 有什么关系

Agent Loop 会反复请求模型:模型调用工具,程序把工具结果追加到 messages,然后再次调用模型。随着循环推进,Prompt 会不断变长。

第 1 次请求:用户任务
第 2 次请求:用户任务 + tool_call + tool_result
第 3 次请求:前面全部内容 + 新的 tool_call + tool_result
第 1 次请求:用户任务
第 2 次请求:用户任务 + tool_call + tool_result
第 3 次请求:前面全部内容 + 新的 tool_call + tool_result

KV Cache 可以加速一次请求内部逐 Token 的 Decode,但它不一定消除多次 HTTP 请求之间的重复 Prefill。这也是为什么成熟 Agent 还要继续考虑:

  • Prompt Cache:复用多个请求共有的前缀。
  • 上下文压缩:减少真正需要送入模型的历史内容。
  • 工具结果裁剪:避免几万行日志占满上下文。
  • KV Cache 管理:在延迟、吞吐量和显存之间取平衡。

到 s08 学习上下文压缩时,你会再次遇到同一个问题:历史信息当然有价值,但并不是保存得越多、每次全部重算就越好。

继续阅读

后续扩展阅读清单

下面只先记录方向,不在这里展开实现细节。

1. 模型推理与性能

  • Tokenizer 如何把文本切成 Token。
  • Context Window 为什么有长度上限。
  • Prompt Cache、Prefix Cache 与 KV Cache 的区别。
  • Continuous Batching 如何提高并发吞吐量。
  • Speculative Decoding 如何加快生成速度。
  • 量化、GQA、MQA 如何减少显存占用。

2. Prompt 与 Context Engineering

  • System Prompt 应该如何分层组装。
  • 长工具结果应该裁剪、总结还是写入文件。
  • RAG、Memory 和当前上下文分别解决什么问题。
  • Prompt Injection 为什么会影响工具安全。
  • 如何计算和控制一次 Agent Loop 的 Token 成本。

3. 代码库理解

  • rg、Glob 和文件读取适合解决什么问题。
  • AST 与 Tree-sitter 如何理解代码结构。
  • LSP 如何提供定义跳转、引用查找和类型信息。
  • Repository Map 如何压缩大型项目结构。
  • Embedding 检索什么时候比关键词搜索更合适。

4. Agent 推理与规划

  • ReAct、Plan-and-Execute 和普通 Tool Loop 的区别。
  • Todo 与持久 Task Graph 应该如何分工。
  • Reflection 和 Critic Agent 是否真的能提高成功率。
  • 如何根据任务难度选择不同模型。
  • Agent 如何判断任务已经真正完成。

5. Agent Runtime

  • Event Loop、消息队列和状态机如何组织长期任务。
  • Checkpoint 如何支持中断、恢复和重试。
  • Background Task 如何处理并发与完成通知。
  • Backpressure 如何避免后台任务无限堆积。
  • Durable Execution 如何让进程崩溃后继续工作。

6. 工具安全与执行隔离

  • Bash Sandbox、容器和虚拟机的隔离边界。
  • Allow、Ask、Deny 权限策略如何落地。
  • API Token 和环境变量如何避免泄漏。
  • 工具调用如何保证幂等性与可重试。
  • 审计日志应该记录哪些执行信息。

7. 多 Agent 协作

  • Subagent、Teammate 和独立进程的区别。
  • Agent 身份、角色与任务能力如何匹配。
  • 消息协议如何处理请求、响应、超时和取消。
  • 多个 Agent 如何避免重复认领同一任务。
  • Git Worktree 如何隔离代码修改与合并冲突。

8. MCP 与插件生态

  • MCP Client、Server 和 Transport 的完整生命周期。
  • Tools、Resources 与 Prompts 三种能力的区别。
  • MCP 服务如何做鉴权、发现和能力协商。
  • 原生工具、MCP 工具和插件如何进入同一调度层。
  • 第三方插件发生故障时如何隔离和降级。

9. 可观测性与评测

  • TTFT、TPS、Token 用量和工具成功率分别代表什么。
  • Trace 如何串起一次完整 Agent Loop。
  • 如何建立可重复运行的 Agent Eval 数据集。
  • 如何区分模型错误、工具错误和运行时错误。
  • 成本、延迟和任务成功率如何一起评估。

10. 人机协作体验

  • Streaming 如何让用户看到实时进度。
  • Approval UI 如何展示危险操作与修改 diff。
  • 用户如何中断、补充信息并恢复任务。
  • Agent 如何解释计划、进度和失败原因。
  • 如何让终端、编辑器和 Web UI 共享同一运行状态。
agent.js
agent.js range=1:10
import {
callLlm,
runChatCli,
isMainModule,
} from "./helper.js";
// s00:最小的“调用大模型”示例 —— 一个 HTTP 请求换一句回答。
// s00: the smallest "call an LLM" example — one HTTP request, one reply.
import {
callLlm,
runChatCli,
isMainModule,
} from "./helper.js";
// s00:最小的“调用大模型”示例 —— 一个 HTTP 请求换一句回答。
// s00: the smallest "call an LLM" example — one HTTP request, one reply.

这 19 节已经把一个 Code Agent 从最小 HTTP 调用推进到可理解的 Agent Runtime:模型负责决策,Loop 维持闭环,工具连接真实环境,Context 与 Memory 管理有限注意力,Task 与 Team 负责长任务协作,MCP 提供外部扩展。

这仍然是教学实现,不是生产平台。真正上线还需要 OS 级 Sandbox、持久化事件日志、并发与崩溃恢复、权限审计、Trace、Metrics、Eval 和成本控制。带着这条边界去读 Pi、OpenCode、Kimi Code 或 DeepSeek Harness,才不会只看到更多工具,而能看到完整系统。