☰
Pi Harness 从零构建编码 Agent:Agent Loop 与工具系统详解
2026/10/27 8:41:13 网站建设 项目流程

1. 从"能聊"到"能干活":Pi Harness 到底解决了什么

很多人第一次接触 Agent 这个概念时,脑子里浮现的是聊天框里那个能对答如流的模型。但真正上手做项目就会发现,能聊和能干活之间隔着一道巨大的鸿沟。模型可以告诉你"你应该先安装依赖,然后运行构建脚本",但它不会真的去帮你敲那行命令;它可以分析一段报错日志,但不会主动去读你的项目文件、定位到具体哪一行出了问题、改完之后再跑一遍验证。这个"从说到做"的跨越,就是 Pi Harness 这类工具存在的意义。

Pi Harness 这个词拆开看很直白:Pi 是核心的编码 Agent 引擎,Harness 是套在它外面的那层"挽具"。你可以把 Pi 想象成一匹力气很大但需要引导的马,Harness 就是缰绳、鞍具和路标的集合体。它负责把用户的自然语言指令翻译成 Agent 能理解的任务描述,管理 Agent 的执行循环,处理工具调用,维护上下文状态,最终把结果反馈给用户。没有 Harness,Pi 只是一个能生成代码文本的模型;有了 Harness,它才变成一个能读文件、跑命令、改代码、验证结果的完整工作流。

这个系列我打算从零开始,把 Pi Harness 的运行机制一层层拆开讲清楚。不管你是刚接触 Agent 开发的新手,还是已经用过一些 Agent 框架但对其内部原理似懂非懂的开发者,这个序篇都会帮你建立一个完整的认知框架。我不会只告诉你"这样调用 API 就行",而是会解释每一步背后的设计意图——为什么 Agent Loop 要这样设计、工具调用的结果为什么要这样处理、上下文窗口快满了的时候该怎么办。这些"为什么"才是你后续自己扩展和排错时真正用得上的东西。

关键词里出现了 TypeScript、Agent Loop、Agent 架构这些词,说明这个系列的技术栈是围绕 TypeScript 生态展开的。如果你之前写过 TypeScript,那阅读起来会很顺畅;如果没写过,也不用担心,我会在涉及类型系统的地方做必要的解释。整个系列的目标是让你最终能自己搭出一个可用的编码 Agent,而不是停留在"跑通 Demo"的层面。

2. Agent Loop:Pi 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 过程中最大的体会是:不要一开始就追求完美。先让最小循环跑起来,哪怕它只能读文件、只能处理最简单的任务。跑通之后你自然会发现哪里不够用、哪里需要改。迭代的过程本身就是最好的学习。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询