Pi Harness 从零构建编码 Agent:Agent Loop 与工具系统详解
1. 从能聊到能干活Pi Harness 到底解决了什么很多人第一次接触 Agent 这个概念时脑子里浮现的是聊天框里那个能对答如流的模型。但真正上手做项目就会发现能聊和能干活之间隔着一道巨大的鸿沟。模型可以告诉你你应该先安装依赖然后运行构建脚本但它不会真的去帮你敲那行命令它可以分析一段报错日志但不会主动去读你的项目文件、定位到具体哪一行出了问题、改完之后再跑一遍验证。这个从说到做的跨越就是 Pi Harness 这类工具存在的意义。Pi Harness 这个词拆开看很直白Pi 是核心的编码 Agent 引擎Harness 是套在它外面的那层挽具。你可以把 Pi 想象成一匹力气很大但需要引导的马Harness 就是缰绳、鞍具和路标的集合体。它负责把用户的自然语言指令翻译成 Agent 能理解的任务描述管理 Agent 的执行循环处理工具调用维护上下文状态最终把结果反馈给用户。没有 HarnessPi 只是一个能生成代码文本的模型有了 Harness它才变成一个能读文件、跑命令、改代码、验证结果的完整工作流。这个系列我打算从零开始把 Pi Harness 的运行机制一层层拆开讲清楚。不管你是刚接触 Agent 开发的新手还是已经用过一些 Agent 框架但对其内部原理似懂非懂的开发者这个序篇都会帮你建立一个完整的认知框架。我不会只告诉你这样调用 API 就行而是会解释每一步背后的设计意图——为什么 Agent Loop 要这样设计、工具调用的结果为什么要这样处理、上下文窗口快满了的时候该怎么办。这些为什么才是你后续自己扩展和排错时真正用得上的东西。关键词里出现了 TypeScript、Agent Loop、Agent 架构这些词说明这个系列的技术栈是围绕 TypeScript 生态展开的。如果你之前写过 TypeScript那阅读起来会很顺畅如果没写过也不用担心我会在涉及类型系统的地方做必要的解释。整个系列的目标是让你最终能自己搭出一个可用的编码 Agent而不是停留在跑通 Demo的层面。2. Agent LoopPi Harness 的心脏是怎么跳动的2.1 一次完整的执行循环长什么样Agent Loop 是整个 Pi Harness 最核心的机制理解了它你就理解了 Agent 运行的本质。我用一个具体的场景来串一遍假设你对 Pi Harness 说帮我看看 src/utils 目录下有没有未使用的导出函数有的话清理掉。这个指令进入 Harness 之后会经历这样一轮循环第一轮Harness 把用户的指令和当前可用的工具列表一起发给 Pi 模型。工具列表里包括读文件、列目录、搜索代码、写文件、执行命令等。模型收到之后判断第一步应该先看看目录结构于是返回一个工具调用请求列出 src/utils 目录下的所有文件。Harness 收到这个请求执行对应的工具函数拿到文件列表把结果追加到对话历史里然后再次调用模型。这是第二轮。第二轮中模型看到文件列表后决定逐个读取文件内容来分析导出情况。它可能一次请求读取多个文件Harness 并行执行这些读取操作把内容返回给模型。第三轮模型分析完所有文件内容判断出哪些导出没有被其他文件引用然后请求写入修改后的文件。Harness 执行写入返回成功确认。第四轮模型确认所有修改完成输出最终的自然语言总结循环结束。这个模型思考→请求工具→Harness执行→结果回传→模型再思考的过程就是 Agent Loop。听起来简单但里面有几个关键的设计决策会直接影响 Agent 的可靠性和效率。2.2 循环终止条件什么时候该停下来最容易被忽视但最重要的问题之一是Agent Loop 什么时候终止如果终止条件设计得不好Agent 可能陷入无限循环——反复读取同一个文件、反复执行同一条命令、或者在两个方案之间来回横跳。Pi Harness 的终止条件通常是多重的。最自然的一种是模型主动输出不含工具调用的纯文本回复这表示它认为任务已经完成。但光靠这个不够因为模型有时候会在任务没完成的情况下就自认为完成了。所以还需要辅助条件设置最大循环轮数比如 50 轮超过就强制终止并报告检测重复行为如果连续三轮都在调用同一个工具且参数相同就中断并提示模型换一种思路。实操中我建议把最大轮数设在 30 到 50 之间。设太低复杂任务做不完设太高一旦模型跑偏会浪费大量 token。这个值可以根据你的任务复杂度动态调整。还有一个细节当模型请求的工具调用执行失败时比如文件不存在、命令返回非零退出码Harness 不应该直接终止循环而是把错误信息作为工具结果返回给模型让它自己决定怎么处理。这是 Agent 和传统脚本最本质的区别——脚本遇到错误就挂了Agent 会看到错误、分析原因、尝试修复。2.3 上下文窗口的管理策略Agent Loop 跑着跑着对话历史会越来越长。每读取一个文件、每执行一条命令结果都会追加到上下文里。一个中等规模的项目几轮下来就可能把上下文窗口撑满。这时候如果不做处理要么请求被模型拒绝要么模型因为上下文太长而注意力涣散开始忽略早期的关键信息。Pi Harness 处理这个问题有几种常见策略。最简单的是滑动窗口只保留最近 N 轮对话更早的内容直接丢弃。但这样做的问题是早期读到的文件内容可能对后续决策仍然重要丢了就可能导致模型重复读取。更精细的做法是做摘要压缩当上下文达到阈值时让模型对之前的对话历史做一次摘要保留关键结论和已完成的步骤丢弃冗余的原始内容。比如已读取了 5 个文件其中 a.ts 和 b.ts 有未使用的导出这样的摘要比保留完整的文件内容要节省大量空间。还有一种策略是外部记忆把重要的中间结果写到文件或数据库里上下文里只保留引用。需要的时候再通过工具调用读回来。这种方式适合长周期任务但增加了实现复杂度。我在实际项目中的经验是对于大多数编码任务滑动窗口加上关键信息摘要的组合就够了。纯滑动窗口太粗暴纯摘要又容易丢细节两者结合性价比最高。3. 工具系统Agent 的手和眼睛3.1 工具定义的结构与类型安全Pi Harness 的工具系统是整个架构里最接地气的部分因为它是 Agent 和真实世界交互的唯一通道。每个工具本质上就是一个函数有名字、有描述、有参数定义、有执行逻辑。模型通过阅读工具的描述来决定什么时候该用哪个工具、参数该怎么填。在 TypeScript 里定义一个工具通常会用到泛型和接口来保证类型安全。比如一个读文件的工具它的参数类型是{ path: string; startLine?: number; endLine?: number }返回值类型是{ content: string; totalLines: number }。这些类型定义不只是给开发者看的它们会被转换成 JSON Schema 发给模型模型据此知道该传什么参数。这里有个容易踩的坑工具描述的质量直接决定模型用得对不对。我见过太多人把工具描述写得极其简略比如读取文件三个字就完事了。结果模型不知道该传相对路径还是绝对路径、不知道文件不存在时会怎样、不知道大文件会不会被截断。好的工具描述应该像一份微型文档说清楚这个工具做什么、参数的含义和格式、返回值的结构、可能的错误情况。3.2 工具执行的安全边界Agent 能执行命令、能写文件这意味着如果不管控它可能造成真实的破坏。Pi Harness 在工具执行层面需要设置安全边界。第一层是路径限制。Agent 操作的文件路径必须限定在项目目录内不能通过../跳出到系统其他位置。实现上可以在执行前对路径做规范化然后检查是否以项目根目录开头。第二层是命令白名单或黑名单。不是所有 shell 命令都适合让 Agent 自由执行。删除操作、网络请求、系统配置修改这类命令需要特别谨慎。一种做法是维护一个允许的命令前缀列表如npm、npx、node、git status等不在列表里的命令需要用户确认。第三层是资源限制。单条命令的执行时间要有上限比如 30 秒输出内容要有大小限制比如超过 10000 字符就截断防止某条命令卡死或产生海量输出把上下文撑爆。我踩过的一个坑早期版本没做输出截断Agent 执行了一条find / -name *.log之类的命令输出直接把上下文窗口撑满了后续所有请求都失败。从那以后我养成了习惯任何可能产生大量输出的工具都必须加截断逻辑。3.3 工具结果的格式化处理工具执行完之后结果怎么返回给模型这个细节对 Agent 的表现影响很大。原始的输出比如命令的 stdout 和 stderr直接扔给模型往往不是最优的因为里面可能包含大量噪音。好的做法是对结果做结构化包装。比如命令执行的结果应该包含退出码、标准输出、标准错误、执行耗时。文件读取的结果应该包含文件内容、总行数、是否被截断。这样模型能快速抓住关键信息而不是在一堆原始文本里大海捞针。对于特别长的输出除了截断之外还可以做智能摘要。比如测试命令的输出可以只保留失败的测试用例和最终的统计行把通过的用例详情省略掉。这种处理需要针对具体工具定制但效果非常明显。4. 上下文工程让模型始终记得住、看得清4.1 系统提示词的设计逻辑系统提示词是 Pi Harness 里最软但影响最大的部分。它决定了 Agent 的角色定位、行为准则和工作方式。写得好Agent 像个靠谱的工程师写得差Agent 像个不听话的实习生。一个有效的系统提示词通常包含几个层次。最上层是角色定义你是一个编码助手帮助用户完成代码相关的任务。然后是行为准则在修改代码前先阅读相关文件执行破坏性操作前先确认遇到不确定的情况主动询问。接着是工具使用指南读取文件时优先使用 read_file 工具而不是执行 cat 命令。最后是输出格式要求完成任务后用简洁的语言总结做了什么。这里的关键是具体。不要写要小心谨慎这种模糊的话要写删除文件前必须先读取该文件确认内容。不要写高效使用工具要写可以并行读取多个不相关的文件以减少轮数。每一条准则都应该是可执行、可验证的。4.2 项目上下文的注入方式Agent 要干活必须了解项目。但项目信息那么多不可能全塞进上下文。Pi Harness 需要一套机制来决定注入什么、怎么注入。最基础的是项目结构概览。可以在启动时扫描项目目录生成一棵简化的文件树忽略 node_modules、.git 等目录注入到系统提示词里。这样模型一开始就知道项目大概长什么样不用每次都从根目录开始探索。更进一步的是关键文件预读。比如 package.json、tsconfig.json、README 这些文件可以在启动时自动读取并注入。模型知道了项目用什么框架、什么构建工具、什么代码规范后续决策会更准确。还有一种做法是维护一个项目知识文件类似 CLAUDE.md 或 .cursorrules让用户手动写一些项目特定的约定和背景。Harness 在每次对话开始时读取这个文件并注入。这种方式灵活度高适合团队协作场景。4.3 对话历史的压缩与检索前面提到了上下文窗口管理这里展开说一下压缩策略的具体实现。滑动窗口的实现最简单维护一个固定长度的消息队列新消息进来就把最老的挤出去。但直接丢弃会导致信息丢失所以通常会保留第一条系统提示词和最近 N 轮对话中间的部分做压缩。压缩的时机很关键。不要等到上下文完全满了才压缩那样可能已经没有空间做压缩操作本身了。通常的做法是设置一个阈值比如上下文窗口的 70%达到阈值就触发压缩。压缩时保留最近几轮的完整对话对更早的内容生成摘要。摘要的生成也有讲究。不是简单地说之前读了几个文件而是要保留对后续决策有影响的信息已经确认的事实比如项目使用 ESM 模块、已经完成的修改比如已经修改了 a.ts 的导出、待解决的问题比如b.ts 的循环依赖还没处理。这些信息用结构化的方式组织比自由文本摘要更可靠。5. 从零跑通第一个 Pi Harness 实例5.1 环境准备与依赖安装理论说了这么多该动手了。跑通一个最小的 Pi Harness 实例你需要准备这些东西。Node.js 环境是基础建议用 18 以上的 LTS 版本。TypeScript 作为开发语言需要安装typescript和ts-node或者用tsx做更快的执行。包管理用 npm 或 pnpm 都行我个人偏好 pnpm安装速度快、磁盘占用小。核心依赖方面你需要一个模型调用的 SDK。具体用哪家取决于你的模型选择但接口设计大同小异传入消息列表和工具定义返回模型的回复和工具调用请求。另外需要一个命令行参数解析库比如commander或yargs来处理用户输入一个文件操作库Node 内置的fs/promises就够用。初始化项目的过程很标准npm init、安装依赖、配置tsconfig.json。tsconfig.json里重点配置target为 ES2022、module为 NodeNext、开启strict模式。strict 模式虽然写起来麻烦一点但能在编译期发现很多潜在的类型错误对 Agent 这种逻辑复杂的项目来说非常值得。5.2 最小可运行循环的实现最小可运行的 Agent Loop 其实不到 100 行代码。核心逻辑是这样的async function agentLoop(userInput: string) { const messages: Message[] [ { role: system, content: SYSTEM_PROMPT }, { role: user, content: userInput }, ]; for (let i 0; i MAX_ITERATIONS; i) { const response await callModel(messages, tools); messages.push(response); if (!response.toolCalls || response.toolCalls.length 0) { return response.content; } for (const call of response.toolCalls) { const result await executeTool(call.name, call.arguments); messages.push({ role: tool, toolCallId: call.id, content: JSON.stringify(result), }); } } throw new Error(达到最大循环轮数任务未完成); }这段代码虽然短但包含了 Agent Loop 的所有核心要素消息历史维护、模型调用、工具调用检测、工具执行、结果回传、终止条件。你可以先把这个跑通然后再逐步添加错误处理、上下文压缩、日志记录等功能。5.3 第一个工具的接入与验证先接入最简单的工具读文件。定义工具描述、参数 schema、执行函数然后注册到工具列表里。const readFileTool { name: read_file, description: 读取指定路径的文件内容。路径相对于项目根目录。, parameters: { type: object, properties: { path: { type: string, description: 文件路径如 src/index.ts }, }, required: [path], }, execute: async ({ path }: { path: string }) { const fullPath resolve(PROJECT_ROOT, path); if (!fullPath.startsWith(PROJECT_ROOT)) { throw new Error(路径越界不允许访问项目目录之外的文件); } const content await fs.readFile(fullPath, utf-8); return { content, lines: content.split(\n).length }; }, };接入之后用一条简单的指令测试读取 package.json 并告诉我项目用了哪些依赖。如果 Agent 能正确调用工具、拿到内容、给出总结说明最小循环跑通了。验证的时候要重点观察几件事模型有没有正确理解工具描述、参数格式对不对、工具执行结果有没有被正确回传、模型有没有基于结果给出合理的回复。任何一环出问题都要回到对应的代码去排查。6. 序篇之后这个系列还会讲什么序篇的任务是建立框架认知让你知道 Pi Harness 由哪些部分组成、每部分解决什么问题。后续的文章会逐个深入这些部分。Agent Loop 的进阶话题包括并行工具调用的调度、流式输出的处理、中断与恢复机制。工具系统会讲如何设计复杂的工具比如带交互的命令执行、带 diff 的文件编辑、如何做工具的组合与编排。上下文工程会深入讲 RAG 在编码场景的应用、多轮对话的状态管理、跨会话的记忆持久化。还有一个重要的方向是 Agent 的安全与可控性。怎么防止 Agent 执行危险操作、怎么审计 Agent 的行为、怎么在自动化和人工确认之间找到平衡点。这些在实际落地时比技术实现更关键。如果你跟着这个系列一路做下来最终应该能拥有一个属于自己的、可定制、可扩展的编码 Agent。它可能不如商业产品那么完善但每一个细节你都清楚遇到问题你知道去哪里找原因想加功能你知道从哪里下手。这种掌控感是用现成工具永远得不到的。我在搭建自己的 Pi Harness 过程中最大的体会是不要一开始就追求完美。先让最小循环跑起来哪怕它只能读文件、只能处理最简单的任务。跑通之后你自然会发现哪里不够用、哪里需要改。迭代的过程本身就是最好的学习。