写在前面:系列是为了帮助大家更好的去理解Agent Harness基础设施,并不是想重复造轮子,真实开发建议选择一个成熟的SDK或Harness框架,才是最合适的选择~
1. 模型只会说,真正干活的是工具
上一篇我用一个约一百行的最小 loop 跑通了「会思考的循环」:模型想、模型说,仅此而已。这个循环是空壳——它能思考,但没有手去碰文件、没有眼睛去看目录。现在我们给 loop 装手和眼睛。
2. 工具定义:ToolDefinition 的四个字段
先给代码。下面两个是真实工具,read_file读文件、list_dir列目录:
constread_file={name:'read_file',description:'读取指定路径的文本文件内容。当用户想查看文件内容时调用。',parameters:{type:'object',properties:{path:{type:'string',description:'要读取的文件路径'},},required:['path'],},tier:'read-only',// 安全等级:015 的审批会用它(先埋个字段)asyncexecute(args){constfs=awaitimport('node:fs/promises')returnawaitfs.readFile(args.path,'utf8')},}constlist_dir={name:'list_dir',description:'列出指定目录下的条目名。当用户想查看目录内容时调用。',parameters:{type:'object',properties:{path:{type:'string',description:'要列出的目录路径'},},required:['path'],},tier:'read-only',asyncexecute(args){constfs=awaitimport('node:fs/promises')constentries=awaitfs.readdir(args.path,{withFileTypes:true})returnentries.map((e)=>(e.isDirectory()?`${e.name}/`:e.name)).join('\n')},}一个 ToolDefinition 就四个关键字段,模型和你的分工各占一半:
name:工具名。模型在回复里用这个名字发起调用;同一注册表里不能重名。description:模型看到的说明,决定模型「何时」选它。它写得好不好,直接决定工具被用的频率——把「何时用」讲清楚的 description,比含糊的强一个数量级。parameters:入参的 JSON Schema。发给模型做参数声明,同时被流水线 pre 阶段用来校验。execute:真正干活的函数。模型永远不直接碰它——这是这套设计的命门,下面展开。
tier: 'read-only'是给【安全边界】预留的字段,本篇先不展开介绍。
第二、三、四个字段合起来,就是我 011 里讲透过的 dshdefineTool心智:schema + render 两层。dsh 的defineTool(schema.ts:545)有五个字段,比我们多一个output,而output又拆schema+render两层——schema 声明 execute 返回的「规范值」,render 把规范值投影成模型可见的内容。
极简版没有output字段,但两层心智没丢,只是挪了位置:
- schema 层=
parameters:发给模型做声明,pre 阶段校验 - render 层= post 阶段的结果规范化(第 4 节,非字符串转 JSON)
dsh 里 output 的validate → freeze → render → snapshot是完整实现(index.ts:1793)。我们先把口子立起来,细节后面补。
3. 注册表:模型怎么看到工具
工具定义好了,接下来是注册表——「模型能看到什么」的控制器。
classToolRegistry{constructor(){this.tools=newMap()// name -> ToolDefinition}register(tool){this.tools.set(tool.name,tool)returntool}// 模型可见视图:把 execute 藏起来,只暴露 schemaschemas(){return[...this.tools.values()].map((t)=>({name:t.name,description:t.description,parameters:t.parameters,}))}get(name){returnthis.tools.get(name)}}注册表干两件事,对应同一个 Map 的两个视图:
- 执行视图:
get(name)从name → ToolDefinition映射里取出 handler,流水线用它调execute。 - 模型可见视图:
schemas()把每个工具压成{ name, description, parameters },藏起 execute,注入给模型。
第二件是命门:模型只通过 description 决定「何时」调用,永远不直接碰 execute。如果模型能看到 execute 的函数体,它就「有手」,不再需要注册表这一层。而 harness 的整个安全思路,恰恰建立在「模型只有嘴,工具才有手」这个边界上,下一篇来讨论。
注入发生在step():
asyncstep(){constmessages=[systemMessage(SYSTEM_PROMPT),...this.history]constout=awaitthis.llm.complete(messages,this.registry.schemas())this.history.push(assistantMessage(out))returnout}每次请求前,把schemas()作为tools参数传给模型。llm/real.js把它转成 OpenAI 格式的tools数组;mock 模型拿它判断「该不该调工具」。模型接口约定就一条:complete(messages, tools) -> Promise<{ text } | { toolCall }>。换真实模型时这一层零改动,换 provider 一行改。
一个细节:注册是 effect。register的本质是Map.set,撤销就是Map.delete。dsh 的ctx.tools.register返回一个 disposer,插件卸载时自动调用。
4. 最小执行流水线:pre → execute → post
模型只输出 tool-call,真正做事的是工具。工具被调用的每一步,都走这条管线:
classToolPipeline{constructor(registry){this.registry=registry}asyncrun(toolCall){consttool=this.registry.get(toolCall.name)if(!tool)return{ok:false,error:`unknown tool:${toolCall.name}`}// pre:校验参数 —— 缺必填参数直接拒绝,工具 body 不碰非法输入if(tool.parameters?.required){for(constkeyoftool.parameters.required){if(toolCall.arguments?.[key]===undefined){return{ok:false,error:`missing required argument:${key}`}}}}// execute:真正干活,带超时(防工具挂死拖垮整个 loop)consttimeoutMs=5000consttimeout=newPromise((_,reject)=>setTimeout(()=>reject(newError(`tool${tool.name}timeout after${timeoutMs}ms`)),timeoutMs),)letvaluetry{value=awaitPromise.race([tool.execute(toolCall.arguments),timeout])}catch(err){return{ok:false,error:`${tool.name}execute failed:${err.message}`}}// post:结果规范化 —— 非字符串转 JSON,保证回写上下文的是稳定形态constcontent=typeofvalue==='string'?value:JSON.stringify(value,null,2)return{ok:true,content}}}三段各管一件事:
- pre:校验参数。缺必填参数直接拒绝,工具 body 不碰非法输入。这一道口,是你对「模型乱传参」的第一道防线。
- execute:真正干活。
tool.execute(toolCall.arguments)就这一行,是你的工具 body。外面包了 timeout——防一个挂死的工具拖垮整个 loop。 - post:结果规范化。非字符串转 JSON,保证回写上下文的是稳定形态。这是 dsh 里
output.render的极简版。
画出来就是这样,你的工具 body 只占中间一环:
注意一个心态:流水线可以短,不能没有。我在 012 拆三家时讲过这条公共要素(公共要素③ 工具流水线)——harness 最少要有 pre 和 post 两道口,pre 管「工具不碰非法输入」,post 管「模型读到稳定形态」。你写的 execute 只是中间一行,前后全是策略的站位。dsh 的完整版在这两道口之间塞进审批、守卫、瀑布,本节的流水线是它的最小版——「最小」不是砍功能,是把必经之口立起来。
5. 跑通它:给你的 loop 装第一个真工具
代码都齐了,跑一遍。把配套工程拉下来,进目录直接:
cdexamples/first-agentnodestep2-tools/index.js不需要npm install,不需要 API key——默认走llm/mock.js确定性 mock 模型。本机真实输出(逐字取自PRACTICE.md):
$ node step2-tools/index.js [user] 读文件 README.md [tool:read_file] -> ok [assistant] 工具 read_file 返回了:# first-agent —— 动手开发你的第一个 agent(系列配套工程) 系列文章《动手开发你的第一个 age… [user] 列目录 .. [tool:list_dir] -> ok [assistant] 工具 list_dir 返回了:llm/ package.json README.md step1-loop/ step2-tools/ step3-s…一轮交互,三行关键输出,每一行都能对上代码:
[user] 读文件 README.md:turn()收到指令,push 进 history,进入循环。[tool:read_file] -> ok:runTool()调流水线,pre → execute → post 走完,打执行标记。[assistant] 工具 read_file 返回了:…:工具结果作为toolResult回写历史,模型下一轮step()读到它,按 system prompt 总结成一句话。
第 3 行背后那层看不见的回写,就是tools/result的极简版,代码就一行:
asyncrunTool(toolCall){constresult=awaitthis.pipeline.run(toolCall)console.log(`[tool:${toolCall.name}] ->${result.ok?'ok':'ERR'}${result.ok?'':result.error}`)this.history.push({role:'toolResult',toolName:toolCall.name,content:result.ok?result.content:result.error})returnresult}history.push({ role: 'toolResult', ... })——工具结果必须回到模型上下文,模型才能继续。
现在看这段最有价值的真实素材。第一次跑读文件 README.md时,README.md 还不存在——真实输出是:
[tool:read_file] -> ERR read_file execute failed: ENOENT: no such file or directory [assistant] 工具 read_file 返回了:read_file execute failed: ENOENT: ...(记录里省了完整错误带的绝对路径;真实输出是ERR read_file execute failed: ENOENT: no such file or directory, open 'D:\...\README.md',mock 把工具结果截到 60 字符。)
这段发生了什么,逐帧拆:
execute里fs.readFile抛 ENOENT——try/catch接住,流水线返回{ ok: false, error: 'read_file execute failed: ENOENT: ...' }。runTool打出[tool:read_file] -> ERR ...,但没有 throw、没有崩溃,loop 照常往下走。- 关键一步:
content: result.ok ? result.content : result.error——错误字符串作为 toolResult 回写历史。错误进了模型上下文。 - 模型下一轮总结:「工具 read_file 返回了:read_file execute failed: ENOENT: …」——模型能读到错误。
后来建了 README.md,同一条命令变成 ok。这证明两件事:
- 工具错误不崩溃 loop,错误进上下文,模型能读到并调整。换成真实模型,它读到 ENOENT 就知道「文件不存在」,下一步可能去创建文件、或者换路径——这不是 demo 话术,是这条流水线天然给出的能力。
- 真实工程的输出随工程状态变化。你今天跑
列目录 ..,目录里会多出PRACTICE.md、step3-safety/、step4-session/——真实代码的输出跟着真实工程走。这就是真实代码和示意代码的区别。
到这里,我要重申开头那个判断:工具是插件,不是特例。你的read_file和 dsh 内置的 bash、fs 工具没有本质区别——都是往注册表注册一个 ToolDefinition,都被同一条流水线接管。区别只在谁写的、有没有随发行版打包。加能力不用等官方,官方功能也是插件,源码就是最好的教材。
想接真实模型?照工程 README,设环境变量后自动切llm/real.js(OpenAI 兼容接口,零依赖 fetch),loop 主体零改动:
OPENAI_BASE_URL=https://api.deepseek.com\OPENAI_API_KEY=sk-xxx\OPENAI_MODEL=deepseek-chat\nodestep2-tools/index.js真实 API 调用我本机没跑(无 key),标「待核实」;签名一致由complete(messages, tools)约定保证。
6. 对照 dsh:你的注册,被一条六阶段流水线接管
先卖个关子:你写的这个注册,最后会被 dsh 一条六阶段流水线接管——猜猜你的代码在哪个环节?看完整条流水线你就知道答案了。
之前 拆过 dsh 的工具执行流水线,位置在packages/core/tools/src/index.ts。六个阶段对应源码:
| 阶段 | 源码 | 干什么 |
|---|---|---|
| ① pre-execute waterfall + 审批 + 单调守卫 | prepareExecution:1463 | 钩子 / 权限 / 沙箱;ctx.approval一次性询问;守卫是不可重排的所有者策略 |
| ② tools/execute 分发 | dispatchScheduledExecution:1569 | timeout / retry / metrics 包在 execute 外面 |
| ③ 你的工具 body | dispatchToolBody:1532 | tool.execute(exec.arguments, exec)这一行 |
| ④ 结果规范化 | createSuccessResult:1793 | validate → freeze → render(011 讲透的 schema+render 两层) |
| ⑤ post-execute waterfall | postExecute:1742 | accept / block / replace / 附加上下文 |
| ⑥ finalizeContent + tools/result | applyFinalContent:1649 /notifyResult:1657 | 定义自带内容变换;冻结的权威结果通知 |
画出来,对照你第 4 节的最小版:
现在兑现那个关子:你的代码在③那一环——dispatchToolBody里的tool.execute(exec.arguments, exec),源码index.ts:1532。紫色高亮的这一行,就是你写的 execute。前后全是策略包裹层。
对照表一拉,dsh 比你的最小版多做了什么:
| 我的最小版 | dsh 六阶段 | dsh 多出什么 |
|---|---|---|
| pre:校验必填参数 | ① prepareExecution | 审批 ask(fail-closed)、单调守卫、瀑布可改写 |
| execute:tool.execute + timeout | ②③ tools/execute + 你的 body | timeout/retry/metrics、取消信号融合 |
| (无) | ④ createSuccessResult | output schema 校验 + render 投影(两层完整版) |
| post:非字符串转 JSON | ⑤ postExecute | post-execute 瀑布可改写、附加上下文 |
| (无) | ⑥ finalizeContent + tools/result | 定义自带内容变换、结果通知 + 活跃批 FIFO |
| toolResult 回写历史 | tools/result 通知 | 事件广播,观察者可监听不可改 |
dsh 多做的三件事,单独说透:
审批和守卫分开。审批是一次性的人机询问,缺了回答方一律 deny——fail-closed;守卫是已注册的所有者策略,不能重新排序。它们在 prepare 阶段先于你的 body 执行。010 里我证过这个结论,这里只提醒:守卫 vs 审批是两回事,别混。
三个瀑布都能改写一次调用。pre-execute、execute、post-execute 都是 waterfall,钩子可以拦截、放行、改写。官方extension-cookbook的映射表写得很清楚:权限门禁挂tools/pre-execute,沙箱走ctx.sandbox,超时重试包tools/execute,结果转换挂tools/post-execute。你的最小版没有瀑布,但它占了「必经之口」的位置——将来要插策略,插的就是这些口子。
finalizeContent + tools/result 是收尾两件套。finalizeContent 应用定义自带的内容变换(最后一道 content-only 不变式);tools/result 通知冻结的权威结果,观察者能读不能改。你的runTool里console.log + history.push,就是这两件事的最简合体。
一句话收束:你的实现是 dsh 的最小版,dsh 是你的超集。六阶段里,你真正写的只有 execute(③ 那一行);其余五段是 harness 白给的——不用你写一行,注册进注册表就自动被接管。
7. 结论:流水线可以短,不能没有
今天给 loop 装上了手和眼睛。回顾这一篇做了四件事:定义工具(四字段)、建注册表(模型可见视图)、走最小流水线(pre/execute/post)、结果回写(tools/result)。跑通一次真实调用,还看了一场真实的 ENOENT 事故现场——错误不崩溃、进上下文、模型能读到。
源码下载:https://download.csdn.net/download/houwenjin/93287753