从 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 调用讲清楚,再进入循环和工具。
问题
大模型不是安装在当前程序里的普通函数。它运行在模型服务商的服务器上,我们必须先回答四个问题:
- 请求发到哪个地址?
- 用哪个 API Token 证明身份?
- 调用哪个模型?
- 用户和模型的对话用什么格式传输?
这些问题没有解决之前,后面的 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。
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,主循环很快会变乱。
解决方案
把工具分成两张表:
新增工具只做两件事:
- 在
tools里增加 schema。 - 在
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 个工具。
这让它更能干,也更危险。
比如用户说:
模型可能会生成:
也可能生成:
这两种都和删除有关,但风险不一样。前者应该直接拒绝,后者也许可以执行,但最好先问用户。
解决方案
在每次工具执行前,加一道三档权限门:
三档结果:
| 决策 | 含义 | 例子 |
|---|---|---|
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 要做的事是:给主循环留插口。
解决方案
定义一组生命周期事件,然后允许外部注册回调。
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>提醒。
流程图
用户输入|vagent loop|+--> 太久没更新 todo?| || +--> 注入提醒: <reminder>Update your todos.</reminder>|vLLM 决定下一步|+--> 调用 todo_write| || +--> 写入 currentTodos| +--> roundsSinceTodo = 0|+--> 每个包含工具调用的模型回合|+--> roundsSinceTodo + 1
用户输入|vagent loop|+--> 太久没更新 todo?| || +--> 注入提醒: <reminder>Update your todos.</reminder>|vLLM 决定下一步|+--> 调用 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 — 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.mdfrontmatter 和正文。listSkills():把已发现的 skill 压成一段简短清单,注入 system prompt 让模型知道有哪些技能。loadSkill({ name }):按名称读取某个 skill 的完整内容,并通过工具结果按需交给模型。agentLoop(messages, confirm):在 s06 基础上多暴露一个load_skill工具。
流程图
启动时|v扫描 skills/|vsystem prompt 只放目录|| 需要某个技能vtool_call: load_skill(name)|v完整技能内容作为 tool_result 回到模型
启动时|v扫描 skills/|vsystem prompt 只放目录|| 需要某个技能vtool_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):当模型提示上下文过长时,立即触发一次总结式压缩。
流程图
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():定期整理记忆,减少重复和过期内容。
流程图
用户输入|v读取 MEMORY.md 索引|v选择相关记忆|v临时放进本次请求|vagent loop|v回合结束:提取新的长期记忆|v写入 .memory/*.md,并重建 MEMORY.md
用户输入|v读取 MEMORY.md 索引|v选择相关记忆|v临时放进本次请求|vagent loop|v回合结束:提取新的长期记忆|v写入 .memory/*.md,并重建 MEMORY.md
.memory/MEMORY.md # 索引:每条记忆一行,便宜、常驻tutorial-style.md # 单条记忆:Markdown + frontmatterproject-facts.md每一轮 agent_loop:1. 读取 MEMORY.md 索引2. 选择和当前任务相关的记忆3. 把记忆正文临时注入本轮请求4. 继续沿用 s08 的上下文压缩5. 回合结束后,从原始消息里提取新记忆
.memory/MEMORY.md # 索引:每条记忆一行,便宜、常驻tutorial-style.md # 单条记忆:Markdown + frontmatterproject-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;工作目录由identitysection 直接读取。
流程图
运行时状态|+--> tools+--> skills+--> memory index+--> compact policy|vassembleSystemPrompt(context)|vsystem prompt|vcall LLM
运行时状态|+--> tools+--> skills+--> memory index+--> compact policy|vassembleSystemPrompt(context)|vsystem prompt|vcall 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,本章只是把它接到“上下文过长”的恢复分支,不是重新实现一套压缩。
流程图
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():把底层任务函数转换成适合返回给模型的文本。
流程图
create_task|v.tasks/{task_id}.json|vpending|| canStart:blockedBy 全部 completed?vclaim_task|vin_progress + owner|vcomplete_task|vcompleted + 解锁下游任务
create_task|v.tasks/{task_id}.json|vpending|| canStart:blockedBy 全部 completed?vclaim_task|vin_progress + owner|vcomplete_task|vcompleted + 解锁下游任务
测试 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 保留前面的全部能力,只改变慢工具的执行方式:
- 模型发出带
run_in_background: true的bash调用,或命令命中慢操作关键词。 - 系统启动异步子进程,立即返回后台任务 id。
- 主 loop 把占位
tool_result回给模型,继续处理其他工具。 - 子进程完成后,结果以新的
<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():测试入口,清空后台任务状态。
流程图
LLM: bash(..., run_in_background=true)|vspawn 异步子进程|+------> 立即返回 bg_0001| || v| 主 loop 继续工作|v子进程完成|v<task_notification> 注入 messages|vLLM 继续
LLM: bash(..., run_in_background=true)|vspawn 异步子进程|+------> 立即返回 bg_0001| || v| 主 loop 继续工作|v子进程完成|v<task_notification> 注入 messages|vLLM 继续
测试 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。
流程图
用户要求 schedule_cron|vscheduleJob 保存 CronJob|vcronTick 每秒检查|v到点任务进入 cronQueue|vprocessCronQueue 自动交付|vagentLoop 注入 [Scheduled] prompt|vLLM 调用工具并输出结果
用户要求 schedule_cron|vscheduleJob 保存 CronJob|vcronTick 每秒检查|v到点任务进入 cronQueue|vprocessCronQueue 自动交付|vagentLoop 注入 [Scheduled] prompt|vLLM 调用工具并输出结果
测试 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。
流程图
lead agent|| spawn_teammate(name, role, prompt)vteammate messages[] .mailboxes/| ^| 独立调用 LLM + tools |v |send_message(to="lead") ----------+|vlead 下一轮 readInbox("lead")|v[Inbox] From alice: ...
lead agent|| spawn_teammate(name, role, prompt)vteammate messages[] .mailboxes/| ^| 独立调用 LLM + tools |v |send_message(to="lead") ----------+|vlead 下一轮 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_approvalpending 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发回队友。
流程图
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 保留这些能力,只增加一条外部工具接入路径:
- 模型调用
connect_mcp连接一个 mock server。 - hub 返回该 server 暴露的工具定义。
- 工具名被改成
mcp__server__tool,避免和内置工具重名。 - 下一轮模型请求使用更新后的工具池和 system prompt。
- 模型调用 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、deployserver,并保存已连接 server。resetMcp():测试入口,重建 hub 并清空 system prompt 缓存。mcpToolSchemas():把已发现工具转成模型可接收的 function tool schema。allTools():合并内置工具与当前已发现的 MCP 工具。findMcpTool(name)/getHandler(name):按名字查找 MCP 定义,并把内置与外部 handler 统一到一个分发入口。runConnectMcp({ name }):连接 mock server,打印新发现的工具,并让 prompt 缓存失效。
流程图
内置 tools|+--------------------------+| |v |connect_mcp("docs") || |v |mock hub 发现 search/version || |v |mcp__docs__search schema ------+|vallTools() -> call LLM -> tool_calls|v同一权限门 -> getHandler() -> tool result 回灌
内置 tools|+--------------------------+| |v |connect_mcp("docs") || |v |mock hub 发现 search/version || |v |mcp__docs__search schema ------+|vallTools() -> 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 = XWqK = XWkV = XWv
Q = XWqK = XWkV = 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...VnDecode 一个新 Token-> 只计算新的 Q、K、V-> 新 K、V 追加到 KV Cache-> 新 Q 对全部缓存 K、V 做 Attention-> 预测下一个 Token
Prefill-> 保存每层的 K1...Kn、V1...VnDecode 一个新 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 不是一回事
这三个概念经常同时出现,但它们处在不同层次:
| 概念 | 保存什么 | 主要作用 | 通常由谁管理 |
|---|---|---|---|
messages | user、assistant、tool 等消息 | 表达对话语义 | Agent 应用 |
| KV Cache | Transformer 每一层的 K/V 张量 | 加速当前生成过程 | 模型推理服务 |
| Prompt / Prefix Cache | 相同 Prompt 前缀对应的可复用计算结果 | 减少跨请求的重复 Prefill | Provider 或推理框架 |
在当前 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 学习上下文压缩时,你会再次遇到同一个问题:历史信息当然有价值,但并不是保存得越多、每次全部重算就越好。
继续阅读
- Transformer 与 Self-Attention 的起点:Attention Is All You Need
- KV Cache 的直观实现与缓存策略:Hugging Face Cache strategies
- 生产级 KV Cache 内存管理:Efficient Memory Management for Large Language Model Serving with PagedAttention
后续扩展阅读清单
下面只先记录方向,不在这里展开实现细节。
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 共享同一运行状态。
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,才不会只看到更多工具,而能看到完整系统。