news 2026/10/2 6:01:13

从零搭建一个 Agent Harness:用 TypeScript + DeepSeek API 跑通最小闭环(TaoToken 统一 Key 接入)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零搭建一个 Agent Harness:用 TypeScript + DeepSeek API 跑通最小闭环(TaoToken 统一 Key 接入)

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改成ES2020,module改成commonjs,outDir设成dist,rootDir设成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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=

拿到 Key 之后,在项目根目录建.env文件:

TAOTOKEN_API_KEY=sk-你的key TAOTOKEN_BASE_URL=https://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.ts:

export interface ToolResult { ok: boolean; data?: any; error?: string; meta: { toolName: string; durationMs: number; timestamp: string; }; } export interface ToolDefinition { name: string; description: string; parameters: Record<string, any>; execute: (args: Record<string, any>) => Promise<any>; } export class ToolRegistry { private tools = new Map<string, 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: Record<string, any>): Promise<ToolResult> { 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里先建 registry:

import * 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/completions,baseUrl 来自.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): Promise<string> { 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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_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 Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=

需要管理多个 Key 或者查看用量,去控制台:

控制台:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=

新建和查看 API Key 在:

API Keys:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=

接入细节和参数说明看文档:

接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=

如果你用 Claude Code 做开发,想把它接到同一套 Key 通道上,参考这个:

Claude Code 接入:https://taotoken.net/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=

下一步我准备给这个 Harness 加两样东西:一是把每轮的RunEvent落盘成 JSON,方便回放调试;二是加一个write_file工具,让模型能真正改代码。到那时候,它就不只是个玩具,而是能帮你干活的家伙了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 6:00:52

pencil插件报错Built assets not found?先构建editor再接入TaoToken

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 6:00:09

2026-10-01-v2-行业热点-飞渡科技51视界漂视网络Q4动态

2026Q4数字孪生赛道新动态&#xff1a;飞渡科技、51视界、漂视网络布局新方向 前言 进入2026年第四季度&#xff0c;国内数字孪生赛道热度持续攀升&#xff0c;头部厂商纷纷加速布局&#xff0c;抢占年末市场份额。本文将聚焦飞渡科技、51视界、漂视网络三家头部企业的最新动态…

作者头像 李华
网站建设 2026/10/2 5:56:43

superpowers 是什么?开发效率增强工具的核心能力与 Java 落地实践

1. 从“superpowers”这个热词说起&#xff1a;它到底是什么第一次看到“superpowers”这个词&#xff0c;很多人会下意识地以为是某个超级英雄题材的游戏或者影视衍生品。但如果你最近在开发者社区、技术群或者代码托管平台上频繁刷到它&#xff0c;就会发现事情没那么简单。这…

作者头像 李华