)
1. 为什么我要手搓一个 Agent Harness 最小闭环Agent Harness 这个词听起来唬人说白了就是「让大模型能自己决定调用哪个工具、拿到结果再继续思考」的那层调度壳子。你平时用 ChatGPT 插件、Cursor 的 Agent 模式、各种自动化工作流背后都跑着类似的东西。它要解决的核心问题只有一个模型本身只会输出文本怎么让它真正去读文件、查数据、写内容然后把结果喂回给自己继续判断。适合谁来跟做这篇写过一点 TypeScript、装过 Node.js、知道npm install是干嘛的就够了。不需要你懂 LangChain也不需要你理解什么 ReAct 论文。我会用最笨但最清楚的方式把 ToolRegistry 注册工具、DeepSeek API 驱动决策、循环执行与结果回填这条链路一行行搭出来。我试过直接上现成框架结果调试的时候根本不知道是模型没返回工具调用还是工具执行炸了还是回填格式不对。所以这次从零写每个环节都打日志、落事件出问题一眼能定位。整篇代码量控制在几百行跑通之后你再去看那些框架的源码会有种「哦原来就是这么回事」的感觉。技术栈定死TypeScript Node.js DeepSeek API。模型选 DeepSeek 是因为它支持标准的 function calling返回结构清晰价格也友好。而 API 通道我会统一走 TaoToken 的 Key这样 endpoint 和密钥管理只在一处配置后面换模型或者加工具都不用动业务代码。下面直接进入目录结构和代码每一步都能复制粘贴跑起来。2. 项目目录结构与 TaoToken 统一 Key 前置配置先把项目骨架搭出来。我不喜欢过度抽象所以目录按职责分一个文件夹干一件事。你在任意空目录下执行mkdir agent-harness cd agent-harness npm init -y npm install typescript ts-node types/node dotenv npx tsc --init然后把tsconfig.json里的target改成ES2020module改成commonjsoutDir设成distrootDir设成src。接着建目录mkdir -p src/runtime src/model src/tools src/engines src/trace touch src/index.ts src/runtime/AgentRuntime.ts src/model/DeepSeekClient.ts src/tools/ToolRegistry.ts src/engines/ToolEngine.ts src/trace/RunEvent.ts最终结构长这样agent-harness/ ├── src/ │ ├── index.ts # 入口接收用户输入 │ ├── runtime/ │ │ └── AgentRuntime.ts # 调度层驱动整个循环 │ ├── model/ │ │ └── DeepSeekClient.ts # 模型适配层封装 API 调用 │ ├── tools/ │ │ └── ToolRegistry.ts # 工具注册与执行 │ ├── engines/ │ │ └── ToolEngine.ts # 执行层处理工具调用循环 │ └── trace/ │ └── RunEvent.ts # 结构化事件 ├── .env ├── package.json └── tsconfig.json现在说 TaoToken 的前置配置。TaoToken 是一个统一 Key 通道你把 DeepSeek 的调用 endpoint 指向它用它的 Key 就能跑。先去官网注册拿 Key官网地址https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content拿到 Key 之后在项目根目录建.env文件TAOTOKEN_API_KEYsk-你的key TAOTOKEN_BASE_URLhttps://taotoken.net/api注意这里 Base URL 是https://taotoken.net/api不带任何多余路径。模型 ID 用deepseek-chat。这三件套Base URL Key Model ID后面在DeepSeekClient里会全部用到缺一个都跑不通。提示.env千万别提交到 git在.gitignore里加上.env和node_modules。package.json的 scripts 加两条{ scripts: { dev: ts-node src/index.ts, check: tsc --noEmit } }到这里前置就绪。下一节开始写真正干活的代码。3. ToolRegistry 与主循环的可复制配置先写工具注册中心。ToolRegistry要干三件事注册工具、根据名字找到工具、执行并统一返回结构。我定义了一个ToolResult协议成功失败都走同一个形状这样主循环处理起来不用写一堆 if。src/tools/ToolRegistry.tsexport interface ToolResult { ok: boolean; data?: any; error?: string; meta: { toolName: string; durationMs: number; timestamp: string; }; } export interface ToolDefinition { name: string; description: string; parameters: Recordstring, any; execute: (args: Recordstring, any) Promiseany; } export class ToolRegistry { private tools new Mapstring, ToolDefinition(); register(tool: ToolDefinition) { this.tools.set(tool.name, tool); } list(): ToolDefinition[] { return Array.from(this.tools.values()); } toOpenAISchema() { return this.list().map((t) ({ type: function as const, function: { name: t.name, description: t.description, parameters: t.parameters, }, })); } async execute(name: string, args: Recordstring, any): PromiseToolResult { const start Date.now(); const timestamp new Date().toISOString(); const tool this.tools.get(name); if (!tool) { return { ok: false, error: 未知工具: ${name}, meta: { toolName: name, durationMs: 0, timestamp }, }; } try { const data await tool.execute(args); return { ok: true, data, meta: { toolName: name, durationMs: Date.now() - start, timestamp }, }; } catch (err: any) { return { ok: false, error: err?.message ?? String(err), meta: { toolName: name, durationMs: Date.now() - start, timestamp }, }; } } }注册两个本地工具一个读文件一个算加法方便验证。在src/index.ts里先建 registryimport * as fs from fs; import { ToolRegistry } from ./tools/ToolRegistry; const registry new ToolRegistry(); registry.register({ name: read_file, description: 读取指定路径的文本文件内容, parameters: { type: object, properties: { path: { type: string, description: 文件相对路径 }, }, required: [path], }, execute: async ({ path }) { return fs.readFileSync(path, utf-8); }, }); registry.register({ name: add_numbers, description: 计算两个数字之和, parameters: { type: object, properties: { a: { type: number }, b: { type: number }, }, required: [a, b], }, execute: async ({ a, b }) { return { sum: a b }; }, });接着写模型客户端。src/model/DeepSeekClient.ts负责把 Base URL、Key、Model ID 三件套拼起来发请求import dotenv/config; export interface ChatMessage { role: system | user | assistant | tool; content: string | null; tool_calls?: any[]; tool_call_id?: string; } export class DeepSeekClient { private baseUrl process.env.TAOTOKEN_BASE_URL!; private apiKey process.env.TAOTOKEN_API_KEY!; private model deepseek-chat; async chat(messages: ChatMessage[], tools?: any[]) { const body: any { model: this.model, messages, }; if (tools tools.length 0) { body.tools tools; body.tool_choice auto; } const res await fetch(${this.baseUrl}/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${this.apiKey}, }, body: JSON.stringify(body), }); if (!res.ok) { const text await res.text(); throw new Error(API 请求失败 ${res.status}: ${text}); } const json await res.json(); return json.choices[0].message; } }注意 endpoint 拼的是${baseUrl}/v1/chat/completionsbaseUrl 来自.env的https://taotoken.net/api最终请求地址就是https://taotoken.net/api/v1/chat/completions。Key 走Authorization: Bearer头。主循环写在src/engines/ToolEngine.ts逻辑是把用户消息和工具 schema 发给模型如果模型返回tool_calls就执行工具、把结果作为tool角色消息追加回去再发一轮直到模型不再要工具、直接给文本答案import { DeepSeekClient, ChatMessage } from ../model/DeepSeekClient; import { ToolRegistry } from ../tools/ToolRegistry; export class ToolEngine { constructor( private client: DeepSeekClient, private registry: ToolRegistry ) {} async run(userInput: string, maxTurns 5): Promisestring { const messages: ChatMessage[] [ { role: system, content: 你是一个会使用工具的助手需要时调用工具。 }, { role: user, content: userInput }, ]; for (let turn 0; turn maxTurns; turn) { const msg await this.client.chat(messages, this.registry.toOpenAISchema()); messages.push(msg); if (!msg.tool_calls || msg.tool_calls.length 0) { return msg.content ?? ; } for (const call of msg.tool_calls) { const args JSON.parse(call.function.arguments || {}); const result await this.registry.execute(call.function.name, args); console.log([tool] ${call.function.name} -, result); messages.push({ role: tool, tool_call_id: call.id, content: JSON.stringify(result), }); } } return 达到最大轮次仍未结束; } }最后src/index.ts串起来import { DeepSeekClient } from ./model/DeepSeekClient; import { ToolEngine } from ./engines/ToolEngine; import { ToolRegistry } from ./tools/ToolRegistry; // ... 前面注册工具的代码 async function main() { const input process.argv.slice(2).join( ) || 帮我算一下 12 加 30 等于多少; const client new DeepSeekClient(); const engine new ToolEngine(client, registry); const answer await engine.run(input); console.log(\n最终回答:, answer); } main();这套配置里Base URL、Key、Model ID 三件套全部集中在DeepSeekClient和.env换通道只改.env两行。工具 schema 由toOpenAISchema()自动生成加工具只改注册处主循环不动。4. 验证请求一次真实工具调用跑通闭环代码写完跑起来验证。先确认.env里 Key 和 Base URL 都对然后执行npm run dev -- 帮我算一下 12 加 30 等于多少预期你会看到类似输出[tool] add_numbers - { ok: true, data: { sum: 42 }, meta: { toolName: add_numbers, durationMs: 1, timestamp: ... } } 最终回答: 12 加 30 等于 42。这一条日志就是闭环跑通的证据模型没有直接瞎编答案而是返回了tool_calls主循环解析出add_numbers和参数{a:12,b:30}ToolRegistry执行后返回ok:true结果被回填成tool消息模型拿到42再生成最终文本。再测一个读文件的先建个README.md写点内容然后npm run dev -- 读取 README.md 并告诉我里面写了什么你会看到[tool] read_file -的日志data字段是文件全文最终回答是模型对内容的总结。如果文件不存在ToolResult会返回ok:false和错误信息模型会基于这个错误告诉你文件读不到而不是整个程序崩掉——这就是统一返回协议的价值。想确认请求真的打到了 TaoToken 通道可以在DeepSeekClient的fetch前加一行console.log(请求地址:, this.baseUrl /v1/chat/completions)跑一次看到打印的地址是https://taotoken.net/api/v1/chat/completions就对了。验证模型本身是否正常也可以直接去模型对话页面发一条消息对比返回模型对话https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content到这里最小闭环完整跑通用户输入 → 模型决策 → 工具执行 → 结果回填 → 模型再决策 → 最终输出。整个过程每一轮的消息数组你都可以console.log(messages)打出来看非常直观。5. 本篇常见报错排查跑不通的时候九成问题出在下面几个地方对照着查。401 Unauthorized。返回体里通常带invalid api key或authentication failed。原因就两个.env里TAOTOKEN_API_KEY没填对或者dotenv没加载。检查DeepSeekClient.ts顶部有没有import dotenv/config以及.env文件是不是在项目根目录和package.json同级。Key 复制时别带空格和换行。local proxy failed / fetch failed。这是网络层没连上不是 Key 的问题。先确认TAOTOKEN_BASE_URL写的是https://taotoken.net/api没有多余斜杠或路径。然后单独测一下连通性curl -X POST https://taotoken.net/api/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer sk-你的key \ -d {model:deepseek-chat,messages:[{role:user,content:hi}]}curl 能返回 JSON 说明通道没问题那就是代码里 URL 拼错了。注意代码里是${baseUrl}/v1/chat/completions别重复拼/v1。Cannot read properties of undefined (reading choices)。说明res.json()返回的结构里没有choices通常是 API 返回了错误对象但 HTTP 状态码是 200或者你直接json.choices[0]没做判断。在DeepSeekClient里加一层const json await res.json(); if (!json.choices || !json.choices[0]) { throw new Error(响应结构异常: JSON.stringify(json)); } return json.choices[0].message;模型不调用工具直接回答。检查toOpenAISchema()返回的数组是不是空的以及chat()里有没有把tools传进去。另外tool_choice设成auto让模型自己决定如果设成none就永远不会调工具。工具描述写清楚点模型才知道什么时候用。tool_calls 里 arguments 解析失败。JSON.parse(call.function.arguments)偶尔会因为模型返回的 JSON 不完整而抛错加个 try-catch 兜底解析失败就回填一个错误结果给模型让它重试let args {}; try { args JSON.parse(call.function.arguments || {}); } catch { args {}; }OAuth / 认证相关报错。如果你用的是某些需要 OAuth 的通道注意 TaoToken 走的是标准 Bearer Token不需要额外 OAuth 流程。确认请求头是Authorization: Bearer sk-xxx不是x-api-key或其他自定义头。排障时最有用的一招是把每轮messages完整打印出来看模型到底收到了什么、返回了什么。大部分「模型不听话」的问题都是消息格式不对导致的。6. 把闭环接进你的日常编码流最小闭环跑通之后你会发现这套东西的扩展点非常清晰。加工具就在registry.register那里加一段主循环和模型客户端完全不用动。想换模型改DeepSeekClient里的model字段就行。想加多轮记忆把messages数组持久化下来即可。如果你打算把这套 Harness 用在长期的编码任务或者 Agent 场景里单次调用按量计费可能不如包月划算。TaoToken 的 Coding Plan 适合这种高频、长时间的开发场景Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content需要管理多个 Key 或者查看用量去控制台控制台https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content新建和查看 API Key 在API Keyshttps://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content接入细节和参数说明看文档接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content如果你用 Claude Code 做开发想把它接到同一套 Key 通道上参考这个Claude Code 接入https://taotoken.net/ClaudeCodeAnthropic?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content下一步我准备给这个 Harness 加两样东西一是把每轮的RunEvent落盘成 JSON方便回放调试二是加一个write_file工具让模型能真正改代码。到那时候它就不只是个玩具而是能帮你干活的家伙了。