ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从零搭建自定义 Harness:Jev 与 Pi 组合实战指南

从零搭建自定义 Harness:Jev 与 Pi 组合实战指南 1. 从零搭建自定义 Harness为什么我选择 Jev 和 Pi 这套组合第一次看到 “Building a Custom Harness with Jev and Pi” 这个标题很多人脑子里冒出来的第一个问题大概是Harness 到底是个啥它跟 Agent 有什么区别为什么不是直接用现成的框架非要自己搭一个我刚开始接触这套东西的时候也是同样的困惑踩了不少坑之后才慢慢摸清楚里面的门道。这篇文章就把我从零搭建一个自定义 Harness 的完整过程拆开来讲包括技术选型的思考、核心模块的实现、TypeScript 配置里那些容易翻车的细节以及实际跑起来之后遇到的各种问题怎么排查。如果你正在做 AI Agent 相关的工程化落地或者对 Pi SDK、Jev 这套组合感兴趣又或者只是单纯想搞清楚 Harness 和 Agent 的边界在哪里那这篇内容应该能帮你省下不少试错时间。先把概念理清楚。Agent 是干活的Harness 是管着 Agent 干活的。打个比方Agent 像是一个能力很强的外包同学你给他一个任务他能自己想办法完成但如果你手下有十个这样的外包同学你需要有人来分配任务、检查进度、处理异常、汇总结果这个“包工头”角色就是 Harness。在 AI 工程领域Harness 通常指的是包裹在模型或 Agent 外面的一层编排框架负责生命周期管理、工具调用路由、上下文注入、错误重试、日志追踪这些脏活累活。Pi 提供的是 Agent 层面的能力Jev 则是在这个基础上做了一层更贴近工程实践的封装两者配合起来你可以用比较少的代码量搭出一个可控性很强的自定义 Harness。为什么不用现成的我试过几个开源的 Agent 框架要么抽象层级太高你想改一个细节得翻半天源码要么太底层什么都要自己写等于没省事。Jev 和 Pi 这套组合的好处在于它把 Agent 的核心循环暴露得比较清楚同时又在工具注册、消息管理这些高频操作上给了足够的便利。你可以把它理解成一个“半成品”骨架已经搭好了但肌肉和皮肤怎么长完全由你决定。这对于需要深度定制 Harness 行为的场景来说比用一个全封闭的框架要舒服得多。这篇文章适合谁看如果你已经写过 TypeScript对异步编程和模块化有基本概念那读起来会很顺。如果你之前没接触过 Agent 相关的东西也没关系我会在关键地方补上背景知识。但如果你连 TypeScript 的tsconfig.json都没打开过建议先花半小时补一下基础不然配置那一节可能会有点吃力。2. 整体架构设计与技术选型背后的取舍2.1 为什么是 Jev Pi 而不是其他组合选型这件事我的原则一直是先看你要解决什么问题再看工具能提供什么。我要搭的 Harness 需要满足几个硬性条件第一能灵活注册和调度工具因为不同任务需要的工具集不一样第二要有清晰的消息流转机制方便我在中间插入自定义的上下文处理逻辑第三类型系统要够强不然改着改着就不知道哪个字段是什么类型了第四不能太重启动和迭代的速度要快。Pi SDK 在这几个维度上表现都不错。它的 Agent 抽象比较干净核心就是一个循环接收输入、决定是否调用工具、执行工具、把结果喂回去、继续循环直到任务完成。这个循环的每一步你都可以挂钩子。Jev 则是在 Pi 的基础上做了一些工程化的增强比如更友好的工具定义方式、内置的重试和超时处理、以及一套比较顺手的日志接口。两者结合你既保留了 Pi 的灵活性又不用自己从头写那些重复的样板代码。对比一下其他方案。有些框架把 Agent 循环藏得很深你只能通过配置文件来调整行为想加一个自定义的决策逻辑就非常别扭。还有些框架虽然开放但类型定义一团糟用 TypeScript 写起来跟写 JavaScript 差不多失去了类型检查的意义。Jev Pi 这套组合在“开放”和“便利”之间找到了一个我觉得比较舒服的平衡点。2.2 Harness 的核心模块划分一个自定义 Harness 不管具体实现怎么变核心模块跑不出这几块Agent 管理器、工具注册中心、上下文管道、执行追踪器、错误处理层。Agent 管理器负责创建、配置和销毁 Agent 实例工具注册中心维护当前可用的工具列表并提供给 Agent 调用上下文管道在消息进入 Agent 之前做预处理比如注入系统提示、裁剪历史消息、附加检索结果执行追踪器记录每一步的输入输出和耗时方便调试和优化错误处理层统一处理超时、工具调用失败、模型返回异常等情况。在 Jev Pi 的体系里Agent 管理器可以直接基于 Pi 的 Agent 类来封装工具注册中心用 Jev 提供的工具定义接口来注册上下文管道和执行追踪器则需要自己实现因为这部分跟具体业务强相关框架很难替你做好。错误处理层可以部分复用 Jev 的内置机制但关键的降级策略和重试逻辑还是得自己写。2.3 TypeScript 配置里的那些坑搭这套东西的过程中TypeScript 配置花了我不少时间。最新版本的 TypeScript 里baseUrl和moduleResolution: node10这两个选项已经被标记为弃用会在 TypeScript 7.0 中停止工作。如果你还在用老配置编译的时候会看到一堆警告。正确的做法是用paths配合moduleResolution: bundler或者node16来替代baseUrl的路径映射功能。具体来说以前你可能这样写{ compilerOptions: { baseUrl: ./src, paths: { /*: [*] } } }现在应该改成{ compilerOptions: { moduleResolution: bundler, paths: { /*: [./src/*] } } }注意paths里的路径要写完整不能再依赖baseUrl做拼接。这个改动看起来小但如果你项目里大量使用了/别名不改的话升级 TypeScript 版本时会直接编译失败。我建议在项目初期就把这个配置写对省得后面迁移麻烦。另外moduleResolution选bundler还是node16取决于你的运行环境。如果你用 Vite 或者 esbuild 做打包选bundler更合适如果是 Node.js 原生 ESM 环境选node16或nodenext。这个选择会影响 TypeScript 如何解析模块路径选错了会出现“找不到模块”的报错但实际文件明明就在那里。3. 核心模块的详细实现与实操要点3.1 Agent 管理器的封装Pi 的 Agent 类本身已经提供了基本的创建和运行能力但直接裸用的话每次都要手动传一堆配置而且多个 Agent 实例之间的隔离和复用不好管理。我的做法是写一个AgentManager类把 Agent 的创建参数收敛到一个配置对象里同时维护一个实例池。import { Agent } from pi-sdk; import { JevToolkit } from jev; interface AgentConfig { name: string; model: string; systemPrompt: string; tools: string[]; maxRetries: number; timeoutMs: number; } class AgentManager { private agents: Mapstring, Agent new Map(); private toolkit: JevToolkit; constructor(toolkit: JevToolkit) { this.toolkit toolkit; } createAgent(config: AgentConfig): Agent { if (this.agents.has(config.name)) { return this.agents.get(config.name)!; } const agent new Agent({ model: config.model, systemPrompt: config.systemPrompt, tools: config.tools.map((name) this.toolkit.getTool(name)), maxRetries: config.maxRetries, timeout: config.timeoutMs, }); this.agents.set(config.name, agent); return agent; } getAgent(name: string): Agent | undefined { return this.agents.get(name); } destroyAgent(name: string): void { const agent this.agents.get(name); if (agent) { agent.dispose(); this.agents.delete(name); } } }这里有几个细节值得说。第一tools字段我存的是工具名称而不是工具对象创建时才从 toolkit 里取这样工具的实现更新后不需要重建 Agent。第二加了实例缓存同名 Agent 不会重复创建避免资源浪费。第三destroyAgent里调用了dispose这个在 Pi 的 Agent 类里是必须的不然事件监听器不会释放跑久了会内存泄漏。注意Pi 的 Agent 实例不是线程安全的如果你在并发场景下使用每个并发任务最好用独立的 Agent 实例或者自己在外面加锁。3.2 工具注册中心的实现Jev 的工具定义接口比 Pi 原生的要顺手一些它允许你用声明式的方式描述工具的输入输出 schema然后自动生成对应的类型定义。我一般会把工具按功能分组比如“文件操作组”、“网络请求组”、“数据处理组”每组一个文件最后统一注册到 toolkit 里。import { defineTool, JevToolkit } from jev; import { z } from zod; const readFileTool defineTool({ name: read_file, description: 读取指定路径的文件内容, input: z.object({ path: z.string().describe(文件路径), encoding: z.enum([utf-8, base64]).default(utf-8), }), output: z.object({ content: z.string(), size: z.number(), }), handler: async ({ path, encoding }) { const fs await import(fs/promises); const content await fs.readFile(path, { encoding }); const stat await fs.stat(path); return { content, size: stat.size }; }, }); const toolkit new JevToolkit(); toolkit.register(readFileTool);用 Zod 来定义 schema 的好处是类型推导是自动的handler里的参数类型直接就是{ path: string; encoding: utf-8 | base64 }不需要手动写类型注解。而且 Jev 会在运行时校验输入如果 Agent 传了不符合 schema 的参数会直接报错而不是让错误蔓延到 handler 里面。工具描述description这块我踩过坑。一开始写得很简略结果 Agent 经常选错工具。后来我把描述写得更具体包括什么场景下用、什么场景下不用、参数怎么填工具选择的准确率明显提升。比如read_file的描述可以写成“读取指定路径的文本文件内容。适用于查看配置文件、日志、源代码等文本类文件。不适用于读取二进制文件或超大文件超过 10MB。”这样 Agent 在决策时就有更明确的依据。3.3 上下文管道的设计上下文管道是 Harness 里最能体现定制化价值的部分。Agent 本身只负责根据当前消息历史做决策但消息历史怎么组织、系统提示怎么注入、外部知识怎么融合这些都是管道要处理的事情。我的管道实现分三个阶段预处理、注入、裁剪。预处理阶段对用户输入做清洗和格式化注入阶段把系统提示、工具说明、检索到的相关文档拼接到消息列表里裁剪阶段根据 token 预算决定保留哪些历史消息。interface PipelineContext { messages: Message[]; systemPrompt: string; retrievedDocs: string[]; tokenBudget: number; } class ContextPipeline { private stages: Array(ctx: PipelineContext) PromisePipelineContext []; use(stage: (ctx: PipelineContext) PromisePipelineContext) { this.stages.push(stage); return this; } async process(ctx: PipelineContext): PromisePipelineContext { let result ctx; for (const stage of this.stages) { result await stage(result); } return result; } }裁剪阶段的 token 计算我用的是简单的字符数除以 4 来估算虽然不精确但够用。更准确的做法是调用模型的 tokenizer但那样会增加一次网络请求在延迟敏感的场景下不划算。我的经验是预留 20% 的 buffer估算值乘以 1.2 再跟预算比较基本不会超。实操心得历史消息裁剪不要简单地从最老的开始删那样会丢掉最早的指令。更好的策略是保留第一条系统消息和最近 N 条对话中间的部分按重要性打分保留高分消息。我实现了一个简单的打分函数给包含工具调用结果的消息更高权重因为那些通常是任务推进的关键信息。3.4 执行追踪与日志追踪器的作用是在每个关键节点打点记录时间戳、输入输出、耗时、token 消耗。这些数据在调试和优化时非常有用。我的实现比较简单就是一个Tracer类提供startSpan和endSpan方法底层用一个数组存 span 记录最后可以导出成 JSON 或者直接打印。class Tracer { private spans: Span[] []; private activeSpans: Mapstring, number new Map(); startSpan(name: string, metadata?: Recordstring, unknown): string { const id ${name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}; this.activeSpans.set(id, performance.now()); this.spans.push({ id, name, metadata, startTime: Date.now(), endTime: 0, duration: 0 }); return id; } endSpan(id: string): void { const start this.activeSpans.get(id); if (start undefined) return; const span this.spans.find((s) s.id id); if (span) { span.endTime Date.now(); span.duration performance.now() - start; } this.activeSpans.delete(id); } export(): Span[] { return [...this.spans]; } }这个追踪器虽然简单但配合日志输出已经足够定位大部分问题了。我一般在 Agent 循环的每一步都打一个 span接收输入、模型推理、工具调用、结果处理。跑一次任务后把 span 列表导出来哪个环节慢、哪个环节出错一目了然。4. 完整实操流程从初始化到跑通第一个任务4.1 项目初始化与依赖安装先建项目目录初始化 npm然后安装核心依赖。我用的包管理器是 pnpm速度快且磁盘占用小你用 npm 或 yarn 也行命令对应改一下就好。mkdir custom-harness cd custom-harness pnpm init pnpm add pi-sdk jev zod pnpm add -D typescript types/node tsxtsx是用来直接运行 TypeScript 文件的省去编译步骤开发阶段很方便。生产环境还是建议用tsc编译后再跑。然后创建tsconfig.json注意前面提到的弃用选项问题{ compilerOptions: { target: ES2022, module: ESNext, moduleResolution: bundler, strict: true, esModuleInterop: true, skipLibCheck: true, outDir: ./dist, rootDir: ./src, paths: { /*: [./src/*] } }, include: [src/**/*] }strict: true一定要开虽然写代码时会多很多类型检查的麻烦但能帮你提前发现大量潜在 bug。skipLibCheck: true可以跳过第三方库的类型检查加快编译速度代价是如果第三方库的类型定义有问题你不会收到警告这个取舍看个人偏好。4.2 搭建 Harness 主循环Harness 的主循环是整个系统的核心。它接收一个任务描述然后驱动 Agent 不断执行直到任务完成或达到终止条件。import { AgentManager } from ./agent-manager; import { ContextPipeline } from ./context-pipeline; import { Tracer } from ./tracer; import { JevToolkit } from jev; interface HarnessOptions { maxIterations: number; tokenBudget: number; onStep?: (step: StepInfo) void; } class Harness { private agentManager: AgentManager; private pipeline: ContextPipeline; private tracer: Tracer; private options: HarnessOptions; constructor(toolkit: JevToolkit, options: HarnessOptions) { this.agentManager new AgentManager(toolkit); this.pipeline new ContextPipeline(); this.tracer new Tracer(); this.options options; } async run(task: string, agentName: string): PromiseHarnessResult { const agent this.agentManager.getAgent(agentName); if (!agent) { throw new Error(Agent ${agentName} not found); } let messages: Message[] [{ role: user, content: task }]; let iteration 0; while (iteration this.options.maxIterations) { iteration; const spanId this.tracer.startSpan(iteration, { iteration }); const ctx await this.pipeline.process({ messages, systemPrompt: agent.systemPrompt, retrievedDocs: [], tokenBudget: this.options.tokenBudget, }); const response await agent.step(ctx.messages); if (response.type final) { this.tracer.endSpan(spanId); return { output: response.content, iterations: iteration, spans: this.tracer.export() }; } if (response.type tool_call) { const toolResult await this.executeTool(agent, response); messages [...ctx.messages, response.message, toolResult]; } this.tracer.endSpan(spanId); this.options.onStep?.({ iteration, response }); } throw new Error(Task did not complete within ${this.options.maxIterations} iterations); } private async executeTool(agent: Agent, response: ToolCallResponse): PromiseMessage { const spanId this.tracer.startSpan(tool_call, { tool: response.toolName }); try { const result await agent.executeTool(response.toolName, response.toolInput); this.tracer.endSpan(spanId); return { role: tool, content: JSON.stringify(result), toolCallId: response.toolCallId }; } catch (error) { this.tracer.endSpan(spanId); return { role: tool, content: JSON.stringify({ error: (error as Error).message }), toolCallId: response.toolCallId, }; } } }这个主循环的逻辑很直白把消息过一遍管道交给 Agent 做一步推理如果 Agent 决定调用工具就执行工具并把结果追加到消息历史里然后继续下一轮。直到 Agent 返回最终结果或者超过最大迭代次数。maxIterations这个参数很关键。设太小复杂任务跑不完设太大万一 Agent 陷入死循环会浪费大量 token。我的经验值是 15 到 25 之间具体看任务复杂度。另外建议加一个“连续无进展”检测如果连续三轮 Agent 都在调用同一个工具且参数相同就主动中断这通常意味着它卡住了。4.3 跑通第一个任务初始化 Harness 并跑一个简单任务import { JevToolkit } from jev; import { Harness } from ./harness; import { readFileTool, writeFileTool, listDirTool } from ./tools; const toolkit new JevToolkit(); toolkit.register(readFileTool); toolkit.register(writeFileTool); toolkit.register(listDirTool); const harness new Harness(toolkit, { maxIterations: 20, tokenBudget: 8000, onStep: (step) { console.log([Step ${step.iteration}] type${step.response.type}); }, }); const result await harness.run( 读取 src 目录下的所有 .ts 文件统计每个文件的行数把结果写入 line-count.json, file-agent ); console.log(任务完成输出, result.output); console.log(总迭代次数, result.iterations);跑这个任务的时候Agent 应该会先调用list_dir列出src目录然后对每个.ts文件调用read_file统计行数最后调用write_file写入结果。如果工具描述写得清楚这个过程通常能在 5 到 8 轮迭代内完成。注意第一次跑的时候建议把onStep回调打开观察 Agent 每一步的决策。如果发现它选了错误的工具或者参数填得不对大概率是工具描述不够清晰回去改描述再试。5. 常见问题排查与避坑经验实录5.1 工具调用失败的各种姿势工具调用失败是最常见的问题表现五花八门。我整理了一个速查表覆盖了我遇到的大部分情况现象可能原因排查方法解决方式Agent 不调用工具直接返回文本工具描述不清晰或系统提示没提工具检查工具 description 和 systemPrompt补充工具使用场景说明调用工具但参数格式错误schema 定义太宽松或描述不明确看 Tracer 里的 tool_call span收紧 schema加参数示例工具执行超时工具内部有阻塞操作检查工具 handler 实现加超时控制异步化工具返回结果 Agent 不理解返回格式太复杂或缺少说明看消息历史里的 tool 消息简化返回结构加字段说明连续调用同一工具Agent 陷入循环看迭代日志加无进展检测主动中断其中“Agent 不调用工具”这个问题我遇到最多。根本原因通常是系统提示里没有明确告诉 Agent 它有哪些工具可用或者工具描述写得太抽象。比如你写“处理文件”Agent 不知道具体是读还是写还是删。改成“读取指定路径的文本文件内容”就明确多了。5.2 上下文超长的处理策略跑长任务的时候消息历史会越来越长最终超出模型的上下文窗口。这时候管道里的裁剪逻辑就派上用场了。我的策略是分三级保留最近 N 条、摘要中间部分、丢弃最老的。具体实现上我先计算当前消息列表的总 token 估算值如果没超预算就直接用。如果超了先把最老的几条非系统消息丢掉直到降到预算的 80% 以下。如果丢掉之后还是超就对中间部分的消息做摘要用一个小模型或者简单的文本压缩算法把多条消息合并成一条摘要消息。摘要这块要注意工具调用的结果消息不能随便丢因为 Agent 后续可能需要引用那些结果。我的做法是给每条消息打一个importance标签工具结果消息标为 high普通对话标为 medium系统消息标为 critical。裁剪时按 importance 从低到高删。5.3 TypeScript 编译报错排查TypeScript 的报错信息有时候比较绕我列几个高频错误和对应的解决思路。Cannot find module xxx or its corresponding type declarations这个错误通常有两个原因一是包没装二是moduleResolution配置不对。先确认node_modules里有这个包然后检查tsconfig.json里的moduleResolution是否跟你的运行环境匹配。如果是 ESM 项目但配了node10就会出这个问题。Type X is not assignable to type Y这种类型不匹配的错误在 Jev 的工具定义里经常出现因为 Zod schema 推导出的类型可能跟你手写的接口有细微差异。解决办法是尽量用z.infertypeof schema来获取类型而不是手写接口。这样类型始终跟 schema 保持同步。Property xxx does not exist on type yyy这种错误如果是第三方库的类型定义不全导致的可以在项目里建一个types目录写一个.d.ts文件做模块增强。比如 Pi SDK 的某个类型缺少一个字段你可以这样补declare module pi-sdk { interface Agent { customField?: string; } }实操心得TypeScript 升级到 5.x 之后moduleResolution的默认值变了很多老项目升级后会突然冒出一堆模块解析错误。建议在tsconfig.json里显式指定moduleResolution不要依赖默认值这样升级时行为可控。5.4 性能优化的几个切入点Harness 跑得慢通常慢在三个地方模型推理、工具执行、上下文处理。模型推理的延迟你控制不了但可以通过减少不必要的推理轮次来间接优化。比如把多个简单工具调用合并成一个批量工具减少往返次数。工具执行这块如果工具有 I/O 操作尽量用异步 API避免阻塞事件循环。我见过有人在工具 handler 里用fs.readFileSync结果整个 Harness 卡住因为 Node.js 是单线程的同步 I/O 会阻塞所有并发任务。上下文处理的开销主要来自 token 计算和消息裁剪。如果消息列表很长每次迭代都重新计算一遍 token 会很浪费。我的做法是缓存每条消息的 token 估算值只在消息内容变化时重新计算。这个优化在长任务里能省下不少 CPU 时间。6. 关于 Harness 工程化的一些个人体会搭完这套东西之后我最大的感受是Harness 的价值不在于它有多复杂而在于它把不确定性收敛到了可控的范围内。Agent 的行为本质上是不确定的同样的输入可能走出不同的路径。Harness 的作用就是给这种不确定性加上边界——超时边界、重试边界、资源边界、错误处理边界。没有这层边界Agent 在生产环境里跑起来就是一场灾难。另一个体会是工具描述的质量直接决定了 Harness 的上限。我花在打磨工具描述上的时间比花在写 Harness 核心逻辑上的时间还多。一个好的工具描述应该像一份给新人的操作手册说清楚这个工具是干什么的、什么时候用、参数怎么填、返回什么、有什么限制。Agent 不是人它没有常识你不在描述里写清楚的东西它一概不知道。最后分享一个小技巧在开发阶段把每次任务的完整消息历史和 Tracer 输出保存到文件里按时间戳命名。跑一段时间后回头翻这些记录你会发现很多之前没注意到的模式比如某类任务总是卡在同一个工具上或者某个 Agent 配置在特定场景下表现特别差。这些洞察是优化 Harness 的一手材料比看文档有用得多。
返回列表