Claude Code 工程化实战:从 Harness 到 Skills,拆解爆款 Agent 的设计密码
1. 从爆款 Agent 源码里我到底该抄什么Claude Code 工程化实战这件事很多人卡在一个误区把爆款 Agent 的源码当成功能清单来抄。看到 OpenClaw 有 Telegram 通道就加一个看到 Hermes 用 TypeScript 写配置就跟着换结果项目越堆越乱跑起来还是三天两头报错。问题不在抄在于没搞清楚这些项目真正做对的是什么。Claude Code 本身是一个具体的 CLI 产品而 OpenClaw、Hermes 这类项目是围绕 Agent 运行时搭起来的骨架。它们能被大量团队拿去改造成自己的东西靠的不是某个炫技功能而是三条主线Harness 负责编排 Agent 的执行循环Channel 负责把不同来源的消息接进来Skills 负责把能力做成可复用、可发现、可降级的模块。这三条线合起来就是一套可维护的 Agent 骨架。这篇文章面向的是已经用过 Claude Code、想把它工程化落地到自己项目里的开发者。我会给出一套可以直接复制的目录结构、配置片段和本地验证步骤让你在自己的仓库里跑通 Harness Channel Skills 的最小闭环。读完之后你应该能判断一个爆款 Agent 的哪些设计值得搬、哪些只是它自己的历史包袱。先说清楚一个前提下面所有配置和代码都是本地可跑的骨架不依赖任何特殊网络环境。你需要准备的只有 Node.js 18、一个可用的模型 API Key以及一个空目录。模型接入部分我会用 TaoToken 作为示例因为它同时提供 Claude 系列和 OpenAI 兼容接口配置起来比较省事但换成任何兼容 OpenAI 协议的服务商下面的结构都成立。我试过把 OpenClaw 的 7 层骨架直接照搬到一个小项目里结果是过度设计——一个内部工具根本不需要 7 层。后来砍到 3 层runtime、channels、skills反而跑得更稳。所以下面的骨架是精简版保留最核心的三条主线你可以按需往上加。2. TaoToken 前置准备把模型接入这步先跑通在写 Harness 之前得先有一个能稳定调用的模型端点。这一步很多人会跳过直接开始写 Agent 循环结果调试时报错分不清是编排逻辑的问题还是模型接入的问题。所以先把接入这步单独验证一遍。TaoToken 的接入方式兼容 OpenAI 协议这意味着你不需要为它单独写一套 SDK 适配层。它的 API 地址是https://taotoken.net/api注意这个地址不带任何查询参数直接作为 base URL 使用。你需要在控制台创建一个 API Key创建入口在https://taotoken.net/console登录后进 API Keys 页面新建即可。拿到 Key 之后先别急着写代码用 curl 验证一次确认 Key 和网络都正常curl https://taotoken.net/api/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -d { model: claude-sonnet-4-20250514, messages: [ {role: user, content: 只回复两个字通了} ], max_tokens: 32 }如果返回的 JSON 里choices[0].message.content是「通了」说明接入没问题。这一步失败的话后面所有 Harness 调试都是白费功夫所以务必先过。关于模型 IDTaoToken 支持 Claude 系列和 OpenAI 系列具体可用列表在文档里能查到地址是https://taotoken.net/doc。我一般会在项目里把模型 ID 抽成环境变量而不是硬编码在代码里这样切换模型不用改代码。环境变量建议这样组织放在项目根目录的.env里TAOTOKEN_API_KEYsk-你的key TAOTOKEN_BASE_URLhttps://taotoken.net/api AGENT_MODELclaude-sonnet-4-20250514 AGENT_MODEL_FASTclaude-haiku-4-5-20251001这里我准备了两个模型 ID一个主力模型跑复杂推理一个快速模型跑分类、路由这类轻量任务。这是从 Hermes 的配置里学来的——它给 default 和 reviewer 配了不同模型reviewer 用更便宜的模型做代码审查成本能降一大截。如果你打算长期跑编码类 Agent可以考虑 Coding Plan它在高频调用场景下比按量计费更划算入口在https://taotoken.net/coding-plan。不过对于本文的骨架验证按量计费完全够用先跑通再说。有一点要提醒不要把 API Key 提交到 git。.env必须进.gitignore团队协作时用.env.example提供模板。这个坑我踩过Key 泄露后只能紧急轮换很麻烦。3. 可复制配置Harness Channel Skills 三件套现在进入正题。下面这套目录结构是我从 OpenClaw 和 Hermes 的骨架里精简出来的保留了三条主线去掉了平台化项目才需要的部署层和前端层。my-agent/ ├── package.json ├── tsconfig.json ├── .env ├── .env.example ├── .gitignore ├── src/ │ ├── harness/ │ │ ├── loop.ts # Agent 执行循环 │ │ ├── types.ts # 核心类型定义 │ │ └── hooks.ts # PreToolUse / PostToolUse 钩子 │ ├── channels/ │ │ ├── interface.ts # Channel 抽象接口 │ │ ├── cli.ts # 命令行通道 │ │ └── http.ts # HTTP 通道 │ ├── skills/ │ │ ├── registry.ts # Skills 注册中心 │ │ ├── read-file.ts # 读文件 Skill │ │ └── run-shell.ts # 执行命令 Skill │ └── index.ts # 入口 └── agent.config.ts # 配置即代码先看agent.config.ts这是整个骨架的配置中心。我采用 Hermes 的「配置即代码」范式用 TypeScript 写配置而不是 YAML好处是能享受类型检查和自动补全// agent.config.ts import { defineConfig } from ./src/harness/types; export default defineConfig({ model: { baseUrl: process.env.TAOTOKEN_BASE_URL!, apiKey: process.env.TAOTOKEN_API_KEY!, default: process.env.AGENT_MODEL!, fast: process.env.AGENT_MODEL_FAST!, }, harness: { maxTurns: 12, maxTokensPerTurn: 4096, stream: true, hooks: { PreToolUse: ./src/harness/hooks.ts, }, }, channels: { cli: { enabled: true }, http: { enabled: true, port: 8787 }, }, skills: { dir: ./src/skills, autoDiscover: true, }, });这个配置里几个关键点值得说明。maxTurns限制 Agent 最多循环多少轮防止它在某个任务上无限打转stream: true开启流式输出这是所有交互式 Agent 的标配用户不用盯着屏幕等 30 秒hooks.PreToolUse指向一个钩子文件用来在工具执行前做拦截比如挡住危险命令。再看 Harness 的核心类型定义这是整个骨架的地基// src/harness/types.ts export interface ToolSchema { name: string; description: string; input: Recordstring, unknown; risk: low | medium | high; execute: (args: Recordstring, unknown) Promiseunknown; } export interface Channel { name: string; start: (onMessage: (text: string) Promisestring) Promisevoid; } export interface AgentConfig { model: { baseUrl: string; apiKey: string; default: string; fast: string; }; harness: { maxTurns: number; maxTokensPerTurn: number; stream: boolean; hooks?: Recordstring, string; }; channels: Recordstring, { enabled: boolean; port?: number }; skills: { dir: string; autoDiscover: boolean }; } export function defineConfig(config: AgentConfig): AgentConfig { return config; }注意ToolSchema里的risk字段。这是从 Hermes 学来的「工具白盒化」——把工具的危险等级显式声明出来Agent 在自主选工具时可以参考钩子也能据此拦截。对比只靠 deny 规则拦的做法显式声明更清晰。Channel 接口设计得很薄只有一个start方法接收一个消息处理函数。这就是「输入适配器」模式Agent 不关心消息从 CLI 来还是从 HTTP 来只管收到一条消息、返回一个回复。CLI 通道和 HTTP 通道各自实现这个接口即可。Skills 注册中心负责扫描skills目录、加载所有工具、生成给模型看的工具描述。这里有个细节工具的description是模型选工具的唯一依据必须写清楚。写「读文件」远不如写「读取文件内容返回带行号的文本适合查看代码或配置」有用。这个坑我在早期项目里踩过工具描述太简略模型经常选错工具。4. 验证请求本地跑通一次完整 Agent 循环配置写完了现在验证它能不能跑。先装依赖npm init -y npm install openai dotenv npm install -D typescript tsx types/node然后写 Harness 的执行循环。这是整个骨架的心脏逻辑不复杂但要处理好流式输出和工具调用// src/harness/loop.ts import OpenAI from openai; import type { ToolSchema } from ./types; export async function runAgentLoop( userInput: string, tools: ToolSchema[], config: { baseUrl: string; apiKey: string; model: string; maxTurns: number } ): Promisestring { const client new OpenAI({ baseURL: config.baseUrl, apiKey: config.apiKey, }); const messages: OpenAI.Chat.ChatCompletionMessageParam[] [ { role: system, content: 你是一个可调用工具的 Agent按需选择工具完成任务。 }, { role: user, content: userInput }, ]; const toolDefs tools.map((t) ({ type: function as const, function: { name: t.name, description: t.description, parameters: t.input, }, })); for (let turn 0; turn config.maxTurns; turn) { const resp await client.chat.completions.create({ model: config.model, messages, tools: toolDefs.length 0 ? toolDefs : undefined, max_tokens: 4096, }); const choice resp.choices[0]; const msg choice.message; messages.push(msg); if (!msg.tool_calls || msg.tool_calls.length 0) { return msg.content ?? ; } for (const call of msg.tool_calls) { const tool tools.find((t) t.name call.function.name); if (!tool) { messages.push({ role: tool, tool_call_id: call.id, content: 错误未找到工具 ${call.function.name}, }); continue; } try { const args JSON.parse(call.function.arguments); const result await tool.execute(args); messages.push({ role: tool, tool_call_id: call.id, content: JSON.stringify(result), }); } catch (err) { messages.push({ role: tool, tool_call_id: call.id, content: 工具执行失败${(err as Error).message}, }); } } } return 达到最大轮次限制任务未完成。; }这段代码有几个设计取舍值得说。第一工具执行失败时不抛异常而是把错误信息作为 tool 消息塞回对话让模型自己决定重试还是换工具——这就是「错误可恢复」范式。第二每轮把 assistant 消息 push 进 messages保证上下文完整。第三maxTurns兜底防止死循环。接着写一个读文件的 Skill 来验证工具调用链路// src/skills/read-file.ts import { readFile } from node:fs/promises; import type { ToolSchema } from ../harness/types; export const readFileTool: ToolSchema { name: read_file, description: 读取指定路径的文件内容返回带行号的文本适合查看代码或配置。, risk: low, input: { type: object, properties: { path: { type: string, description: 文件的绝对路径 }, }, required: [path], }, async execute(args) { const path args.path as string; const content await readFile(path, utf-8); const lines content.split(\n).map((l, i) ${i 1}\t${l}); return { path, total_lines: lines.length, content: lines.join(\n) }; }, };最后写入口把 Harness 和 Skill 串起来// src/index.ts import dotenv/config; import { runAgentLoop } from ./harness/loop; import { readFileTool } from ./skills/read-file; async function main() { const result await runAgentLoop( 读一下 package.json告诉我项目名和依赖数量, [readFileTool], { baseUrl: process.env.TAOTOKEN_BASE_URL!, apiKey: process.env.TAOTOKEN_API_KEY!, model: process.env.AGENT_MODEL!, maxTurns: 8, } ); console.log(\n Agent 最终回复 ); console.log(result); } main().catch(console.error);跑起来npx tsx src/index.ts如果一切正常你会看到模型先调用read_file读取 package.json拿到内容后总结出项目名和依赖数量。这就是一个最小可用的 Agent 循环——Harness 编排、Skill 提供能力Channel 暂时用 CLI 入口代替。想验证 HTTP 通道的话把src/channels/http.ts补上用 Node 内置的http模块起一个服务收到 POST 请求就调runAgentLoop返回结果。这样你的 Agent 就能被其他系统调用了。Channel 的价值就在这里同一套 Harness换个入口就能接不同来源的消息。5. 本篇常见错排查401、工具不触发、循环打转骨架跑起来之后报错基本集中在这几类。我把真实遇到过的错误和排查路径列出来你对照着看。401 Unauthorized。这是最常见的八成是 Key 没读到。先确认.env文件在项目根目录且import dotenv/config在入口文件最顶部——如果它排在runAgentLoop的 import 之后环境变量还没加载就被读取了拿到的是 undefined。再确认 Key 没有多余空格复制粘贴时经常带上换行。最后用第 2 节的 curl 命令单独验证 Key 本身是否有效排除 Key 被禁用或额度耗尽的情况。工具不触发模型直接回答。模型没调工具通常是description写得太模糊或者input的 JSON Schema 有问题。检查parameters里type是不是objectproperties和required是否对应。另外如果tools数组为空toolDefs就是空数组传给 API 时会被忽略模型自然不调工具。还有一种情况模型觉得这个问题不需要工具就能答比如你问「11 等于几」它不会去读文件。换个明确需要工具的任务再测。循环打转达到 maxTurns。模型反复调同一个工具、拿不到有用结果就会一直转。排查方向工具返回的内容是不是模型看不懂的格式比如返回了一个嵌套很深的 JSON模型解析不了就会重试。把工具返回值拍平成简单结构或者加一个summary字段用自然语言描述结果。另一个原因是工具执行一直失败错误信息又不够明确模型不知道该怎么改。确保错误信息里带上具体原因比如「文件不存在/path/to/x」而不是「读取失败」。local proxy failed / connection refused。这类错误说明请求根本没发出去。检查baseUrl是不是写成了https://taotoken.net/api/v1还是https://taotoken.net/api——OpenAI SDK 会自动在 baseURL 后面拼/chat/completions所以 baseURL 应该到/api为止不要带/v1。如果你在本地配了 HTTP 代理确认代理没有拦截这个域名。企业网络环境下防火墙可能挡了出站请求这个需要找网络管理员确认。reading choices of undefined。这个报错说明 API 返回的结构和预期不符通常是请求本身失败了但没抛异常。打印完整的resp看看常见原因是模型 ID 写错了服务端返回了一个错误对象而不是正常的 completion 结构。对照文档确认模型 ID 拼写注意大小写和日期后缀。OAuth / 认证方式混淆。如果你之前用过 Claude Code 的 OAuth 登录可能会想当然地以为 API 调用也走 OAuth。不是的API 调用走的是 Bearer Token就是你在控制台创建的 API Key。这两套认证是独立的别混用。Claude Code 的 OAuth 是给 CLI 工具本身用的你的 Agent 代码里用的是 API Key。排查时有个通用技巧在runAgentLoop里把每轮的messages打印出来看模型到底收到了什么、返回了什么。大部分问题看一眼对话历史就清楚了。这个调试开关建议做成环境变量控制生产环境关掉。6. 把范式带回自己的项目跑通最小闭环之后下一步是把这套骨架扩展成你项目真正需要的样子。这里给几条实操建议都是从爆款项目里提炼出来的。Skills 的发现机制值得做扎实。现在autoDiscover只是扫描目录你可以进一步给每个 Skill 加tags和version字段让注册中心支持按标签筛选、按版本降级。OpenClaw 的 Skills 注册中心就是这么做的当某个 Skill 加载失败时它会自动降级到上一个可用版本而不是整个 Agent 崩掉。Channel 的抽象要守住。我见过不少项目一开始只做 CLI后来要加 Web 入口时把 Harness 逻辑复制了一份到 HTTP handler 里结果两套逻辑逐渐分叉改一个 bug 要改两处。正确做法是 Harness 只暴露一个runAgentLoop函数所有 Channel 都调它Channel 层只负责消息的收发和格式转换。Hooks 是审计和安全的抓手。PreToolUse钩子可以在工具执行前检查参数比如挡住rm -rf这类危险命令或者检测参数里有没有敏感信息。PostToolUse钩子可以记录每次工具调用的输入输出方便事后回放。企业场景下这两个钩子是刚需早点留好扩展点。模型分层能省不少成本。把路由、分类、简单问答交给快速模型复杂推理和代码生成交给主力模型。Hermes 的 reviewer agent 用便宜模型做代码审查就是这个思路。你可以在 Harness 里加一个routeModel函数根据任务类型选模型配置里已经预留了default和fast两个 ID。最后说一个心态问题。读爆款源码的价值不在于抄功能而在于理解它们为什么这么设计。OpenClaw 做 7 层是因为它要支撑平台化生态你的内部工具做 3 层就够了。Hermes 用 TypeScript 写配置是因为它面向工程团队你的个人项目用 JSON 也没问题。关键是搞清楚每个设计选择背后的约束然后判断这个约束在你的场景里成不成立。这套骨架你可以直接 clone 下来改也可以只挑 Harness 那部分嵌进现有项目。跑通之后建议你拿一个真实的小任务测一测比如「扫描 src 目录找出所有超过 500 行的文件并列出文件名」。这种任务需要多轮工具调用能检验 Harness 的循环、Skill 的协作和错误恢复是否都正常。测通了你就有了一个可以持续往上加能力的 Agent 底座。