返回文章列表

从0到1用 Harness 工程搭建出一个类Deepseek Harness的Agent

我将从零开始搭建我的第一个Agent,并在此过程中熟悉Harness架构及Agent原理

Admin
12 分钟阅读
13 次阅读
最后编辑于 2026-08-21 14:46
从0到1用 Harness 工程搭建出一个类Deepseek Harness的Agent

写在前面

什么是 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步: 把用户的问题作为第一条消息。

typescript
// 消息接口定义
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。

typescript
// 工具接口定义
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给出的工具调用指令直到结束。

typescript

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 的那个消费者。

所以:

typescript
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() 的实现:

javascript
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 插件时是这样做的:

typescript
import { AgentLoop } from './plugins/agent-loop.js';  // 这里AgentLoop实际是agent-loop.js中导出的class
...
await ctx.plugin(AgentLoop);  // 直接将class传入了

所以 cordis 会直接读 AgentLoop 这个class的属性 Config ,这实际上不存在,所以我们对 agent-loop.ts 做如下更更改:

typescript
...
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:

typescript
await ctx.plugin(AgentLoop, { maxTurns: 20 });  // 实际的config

这样 AgentLoop 的Config就生效了。

问题 3: 当LLM需要调用一些阻塞型命令、网络挂起、死循环命令时, 整个 agent 僵死

具体危害场景:

  • 阻塞型命令:ping -tpause、任何等待 stdin 的交互式程序
  • 网络挂起:curl/ssh 连不上但也不报错,傻等
  • 死循环命令:for /l %i in (0,0,1) do echo hi 之类
  • 模型"猜错"命令:比如让它列大目录,它来个 dir /s,几百万个文件扫不完

解决方案:

typescript
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指出错误。

typescript
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; // 继续下一轮循环
            }
            ...
        }
        ...
    }
}

登录后发表评论

请先登录账号后再发表评论