写在前面
什么是 Harness 工程?
开发智能体,不是写一堆 if-else、搭建工作流,然后把模型接进来。
智能体就是大模型本身——它天生会推理、会决策。你要做的,是给它搭一个干活的环境:能用什么工具、能看什么文件、怎么和别人协作。
这个环境,就叫 Harness;这个过程,就是 Harness 工程。
Harness = 工具 + 知识 + 上下文管理 + 权限边界
打个比方:
模型是司机,Harness 是车。你不需要教司机怎么开车,你只需要造一辆好车。
1. 背景介绍
本人希望系统学习基于 Harness 架构的 Agent 开发流程,因此借鉴 GitHub 上的 learn-claude-code 教程,使用 TypeScript + Cordis 实现一个类似 deepseek-harness 的项目。
关键在于理解两者的设计哲学,并将其融合。
2. 核心思路:从“Bash 胶水”到“一切皆插件”
learn-claude-code 的精髓在于,它用最轻量的方式(Bash + 外部 API 调用)搭建了一个 Agent 的“骨架”,清晰阐述了:
Agent = Model + Harness
并强调“智能”来源于模型,而 Harness 是为模型提供感知、推理和行动能力的“载具”。
而 deepseek-harness 则更进一步,将 “一切皆插件”(Everything is a plugin) 作为核心架构,并选用 Cordis(强大的 TypeScript 元框架)作为其“心脏”。
因此,我们的目标可以概括为:
将
learn-claude-code中的“Bash 胶水”逻辑,用 TypeScript 重写,并基于 Cordis 框架,重构为一个高扩展性的插件化系统。
Chapter 1 最小智能体——其实就是一个循环
构建一个智能体,核心就是一个循环:让智能体不断调用工具,直到它自己判断不再需要调用为止。
整个过程完全交给智能体来决定:
- 把用户的问题丢给大模型;
- 大模型觉得需要用工具,就用;
- 用完把结果喂回去,大模型继续思考;
- 想完了要么继续用工具,要么直接给你答案。
什么时候停?
大模型自己决定,不需要你写任何判断逻辑。
一个 while true 循环,模型调用工具就继续,不调用就停。循环直接检查响应里的内容块:
| 信号 | 含义 | 循环动作 |
|---|---|---|
包含 tool_use block | 模型要求调用工具 | 执行 → 结果喂回去 → 继续 |
不包含 tool_use block | 模型没有调用工具 | 退出循环 |
工作原理
将这个过程翻译成代码。分步来看:
第1步: 把用户的问题作为第一条消息。
// 消息接口定义
export interface ChatMessage {
role: 'system' | 'user' | 'assistant' | 'tool'; // 系统提示、用户输入、助手回复、工具调用
content: string | null; // 内容,null 表示工具调用
reasoning_content?: string; // 推理内容
tool_call_id?: string; // 工具调用ID
tool_calls?: {
id: string;
type: string;
function: { name: string; arguments: string };
}[]; // 工具调用列表
}
// 系统提示词
const system_prompt = `你是一个编码 agent。需要执行命令时调用 bash/sh/cmd 工具,命令结果会作为 tool 消息返回给你。
当任务完成或无需更多操作时,用自然语言回复用户。`;
// 用户提示词
const prompt = '帮我列出当前目录的文件';
// 用户消息队列
const messages: ChatMessage[] = [
{ role: 'system', content: system_prompt },
{ role: 'user', content: prompt },
];
第2步: 将消息和工具定义一起发给 LLM。
// 工具接口定义
export interface Tool {
name: string; // 工具名称
description: string; // 工具描述
input_schema: Record<string, unknown>; // 输入参数模式
run: (args: any) => Promise<string>; // 运行函数
}
// 注册工具
this.register({
name: 'bash',
description: '在用户的机器上执行一段bash/shell命令',
input_schema: {
type: 'object',
properties: { command: { type: 'string', description: '要执行的bash/shell命令' } },
required: ['command'],
},
async run({ command }: {command: string}) {
try {
const { stdout, stderr } = await execAsync(command);
return stdout + (stderr ? `\n${stderr}` : '');
} catch (error: any) {
return `[exit code: ${error.code ?? 1}]\n${error.stdout ?? ''}${error.stderr ?? ''}`;
}
}
});
// 发送消息和工具定义给llm
const msg = await llm.chat(messages, tools.list());
第3步: 循环执行llm给出的工具调用指令直到结束。
for(let turn = 0; turn < maxTurns; turn++) {
const msg = await llm.chat(messages, tools.list()); // 调用LLM生成回复
console.log(msg); // 打印回复
if(msg.tool_calls?.length) { // 如果回复包含工具调用
messages.push({
role: 'assistant',
content: msg.content ?? null,
tool_calls: msg.tool_calls,
reasoning_content: msg.reasoning_content
}); // 添加助手回复消息
for(const call of msg.tool_calls) { // 遍历工具调用
const fn = call.function;
const args = JSON.parse(fn.arguments || '{}'); // 解析工具调用参数
if(!args) throw new Error('tool arguments not valid'); // 校验参数是否有效
this.ctx.logger.info(`[tool] %s %o`, fn.name, args); // 打印工具调用参数
const result = await tools.run(fn.name, args); // 调用工具服务
messages.push({ role: 'tool', tool_call_id: call.id, content: result }); // 添加工具调用结果消息
}
continue; // 继续下一轮循环
}
return msg.content ?? ''; // 返回助手回复
}
问题修正
问题 1: 运行pnpm tsx src/main.ts后,发现控制台没有日志输出
在 Cordis 的日志体系里,ctx.logger.info(...) 本身并不会直接往 stdout/stderr 写数据,它只是"生产"了一条日志记录(一个事件)。
日志的输出依赖消费者(Exporter):必须有人订阅这些日志记录、再决定写到哪里(控制台/文件/远程)。ConsoleExporter 就是把日志记录写到 process.stdout / process.stderr 的那个消费者。
所以:
new ConsoleExporter(ctx);
这一行的作用是把"控制台输出器"注册到 ctx 上。如果没有它:
ctx.logger.info('[main] final answer: \n%s', answer)等调用仍然会生成日志事件;- 但没有任何 exporter 去消费这些事件,于是控制台什么都打不出来。
这也是 Cordis(以及 Koishi 沿用的)设计:日志的"产生"与"输出"是解耦的,你可以同时挂多个 exporter(比如再装一个 file exporter)输出到不同地方,互不干扰。这也是为什么这一行是"打印控制台日志"的必要条件。
问题 2: agent-loop中的export const Config并没有生效
我们查看 cordis 源码,发现如下 ctx.plugin() 的实现:
plugin(plugin, config, getOuterStack) {
const callback = this.resolve(plugin);
...
runtime = { name, callback, fibers, Config: plugin.Config }; // ← 读 plugin.Config
...
const fiber = new Fiber(this.ctx, config, Inject.resolve(plugin.inject), runtime, ...); // ← 读 plugin.inject
}
// resolve:plugin 是函数/类,还是带 apply 的对象
resolve(plugin) {
if (typeof plugin === "function") return plugin; // 类 → 返回类本身
if (isApplicable(plugin)) return plugin.apply; // 带 apply 的对象 → 返回 apply
}
可以看到程序是直接读传入的 plugin.Config 作为Config使用,但是我们在注册 agent-loop 插件时是这样做的:
import { AgentLoop } from './plugins/agent-loop.js'; // 这里AgentLoop实际是agent-loop.js中导出的class
...
await ctx.plugin(AgentLoop); // 直接将class传入了
所以 cordis 会直接读 AgentLoop 这个class的属性 Config ,这实际上不存在,所以我们对 agent-loop.ts 做如下更更改:
...
export class AgentLoop extends Service {
static inject = ['llm', 'tools']; // 改用类静态属性实现
static Config = z.object({
maxTurns: z.number().default(20)
}); // 改用类静态属性实现
...
}
export const inject = ['llm', 'tools']; // 删除此定义
export const Config = z.object({
maxTurns: z.number().default(20),
}); // 删除此定义
经过上面修改后,ctx.plugin() 就可以通过 plugin.Config 正确读取到配置模板了,然后我们别忘了在注册插件的时候传入实际的config:
await ctx.plugin(AgentLoop, { maxTurns: 20 }); // 实际的config
这样 AgentLoop 的Config就生效了。
问题 3: 当LLM需要调用一些阻塞型命令、网络挂起、死循环命令时, 整个 agent 僵死
具体危害场景:
- 阻塞型命令:
ping -t、pause、任何等待stdin的交互式程序 - 网络挂起:
curl/ssh连不上但也不报错,傻等 - 死循环命令:
for /l %i in (0,0,1) do echo hi之类 - 模型"猜错"命令:比如让它列大目录,它来个 dir /s,几百万个文件扫不完
解决方案:
try {
const { stdout, stderr } = await execAsync(command, { timeout: 30_000 }); // 加超时时间,超过这个时间后 Node 强制 kill 子进程
return stdout + (stderr ? `\n${stderr}` : '');
} catch (error: any) {
return `[exit code: ${error.code ?? 1}]\n${error.stdout ?? ''}${error.stderr ?? ''}`; // 捕获错误情况并返回错误信息 供LLM修正
}
当运行命令出现超时或报错时,AgentLoop会将报错信息发给LLM,让LLM自行修正。
问题 4: 当LLM“抽风”时,可能会出现重复要求执行某一指令的情况
针对这一问题,我们给AgentLoop加上工具调用指纹规则, 当LLM重复maxTurns次给出同样的命令与参数时,主动结束循环,并向LLM指出错误。
export class AgentLoop extends Service {
static Config = z.object({
maxTurns: z.number().default(20),
maxRepeat: z.number().default(3), // 增加最大指令重复次数配置
});
private lastFP: string | null = null; // 上一次的指令指纹
private repeatCount = 0; // 指令重复次数
// 函数用于检查是否重复
private repeatCheck(fp: string): boolean {
if(fp === this.lastFP) {
this.repeatCount++;
} else {
this.repeatCount = 1;
this.lastFP = fp;
}
return this.repeatCount >= this.config.maxRepeat;
}
...
async run(prompt:string) {
...
// 初始化工具调用指纹列表
this.lastFP = null;
this.repeatCount = 0;
// 循环执行
for(let turn = 0; turn < this.config.maxTurns; turn++) {
...
if(msg.tool_calls?.length) { // 如果回复包含工具调用
...
for(const call of msg.tool_calls) { // 遍历工具调用
...
// 检查工具调用指纹是否已存在
const fp = `${fn.name},${fn.arguments}`; // 生成工具调用指纹
if(this.repeatCheck(fp)) { // 如果达到最大指令重复次数阈值,提示LLM改变策略
messages.push({
role: 'tool',
tool_call_id: call.id,
content: `[error] 检测到连续重复调用同一工具,请改变策略,避免无限循环。当前重复次数:${this.repeatCount}次。`,
});
continue; // 跳过这个工具调用
}
...
}
continue; // 继续下一轮循环
}
...
}
...
}
}
目录
作者
Petal
全栈开发者