极简 Agent Harness 实战:从 Pi 源码到自建 CLI 智能体
1. 为什么一个“极简”的 Agent Harness 值得单独拿出来讲第一次看到 Pi 这个项目的时候我的反应是有点不以为然的。10w stars 的项目我见过不少但大多数要么是功能堆到臃肿要么是文档写得像天书真正能让人在半小时内跑起来、并且看懂内部逻辑的少之又少。Pi 不一样它的定位非常明确一个极简的 Agent harness用 TypeScript 写跑在 CLI 里把 LLM 的能力包装成一个可以真正干活的智能体。这里有个概念必须先掰扯清楚因为我在社区里看到太多人把 harness 和 agent 混着用。Agent 是“谁在干活”harness 是“怎么让这个家伙干活”。打个比方Agent 就像是一个刚招进来的实习生有脑子、能理解你说的话但他不知道怎么用公司的系统、不知道文件放在哪、不知道遇到报错该找谁。Harness 就是那套入职培训加工具箱告诉他怎么读文件、怎么执行命令、怎么把结果整理成报告。Pi 做的就是这套 harness而且做得极其克制。你可能会问市面上已经有那么多 LLM 框架了LangChain、AutoGPT、各种 agent 编排工具为什么还要看 Pi我的实际体验是大部分框架的问题在于抽象层太多。你想让模型读一个文件得先定义一个 Tool再注册到一个 ToolRegistry再配置一个 Executor最后还要处理各种 callback。Pi 的思路完全相反能一行代码搞定的事绝不写三行。它的核心代码量非常小小到你可以在一个下午通读一遍然后完全理解一个 agent 从接收指令到执行动作的完整链路。这篇文章适合谁看如果你是那种想搞清楚 agent 底层到底怎么运转、不想被框架黑盒困住的人Pi 是一个非常好的切入点。如果你已经在用 Claude CLI、Codex CLI 这类工具但好奇它们内部是怎么把 LLM 的输出变成实际操作的Pi 的源码就是最好的教材。哪怕你只是 TypeScript 的中级使用者想看看一个生产级的 CLI 工具是怎么组织的这里面的工程实践也值得一读。我接下来会从设计思路、核心机制、实操搭建、常见坑四个维度把 Pi 这个东西彻底拆开。不是那种“官方文档翻译”式的讲解而是我自己跑通、踩坑、改代码之后的理解。你跟着走一遍应该能自己动手做一个类似的 harness或者至少在看其他 agent 工具的时候一眼就能看出它的 harness 层是怎么设计的。2. Pi 的整体设计思路与核心架构拆解2.1 极简主义背后的工程取舍Pi 的“极简”不是功能少而是抽象层级少。我读过它的核心源码之后最大的感受是作者在每一个可能引入抽象的地方都选择了克制。比如工具调用很多框架会定义一个 BaseTool 抽象类然后让每个工具去继承再搞一个 ToolManager 来管理生命周期。Pi 的做法是工具就是一个普通的对象有 name、description、execute 三个字段execute 是一个返回 Promise 的函数。没了。这种设计的好处是什么调试成本极低。当你的 agent 执行一个操作失败的时候你不需要在五层继承关系里找问题出在哪。你直接看那个 execute 函数看它接收了什么参数、返回了什么结果、抛了什么异常。我试过在一个复杂框架里排查一个工具调用失败的问题花了将近两个小时才定位到是一个 middleware 在偷偷修改参数。在 Pi 里这种问题不存在因为根本没有 middleware 这一层。另一个取舍是不做内置的 memory 管理。Pi 把对话历史直接放在一个数组里每次调用 LLM 的时候把整个数组传过去。这听起来很粗暴但你想一想对于 CLI 场景下的 agent大部分任务的对话轮次不会超过几十轮上下文窗口完全放得下。那些复杂的 memory 压缩、向量检索、摘要生成在这个场景下反而是过度设计。我实测下来一个中等复杂度的代码重构任务对话历史大概在 15 到 20 轮左右token 消耗完全在可控范围内。当然这种极简也有代价。如果你要做一个长期运行的、需要记住几个月前对话的 agentPi 的默认方案就不够用了。但 Pi 的定位本来就不是那个场景它要解决的是“给我一个能立刻上手、能看懂、能改的 agent 骨架”。在这个定位下极简是优势不是缺陷。2.2 核心模块的职责划分Pi 的代码结构非常清晰主要分四个部分。我用一个表格来对比它们的职责和我在实际使用中的感受模块职责实际使用感受CLI 入口解析命令行参数、初始化配置、启动交互循环非常薄基本就是读参数然后调核心逻辑Agent 核心管理对话历史、调用 LLM、解析工具调用请求逻辑集中一个文件就能看完工具集提供文件读写、命令执行、搜索等基础能力每个工具独立想加就加想删就删LLM 适配层封装不同 LLM 提供商的 API 差异接口统一换模型只需要改配置这个划分的关键在于LLM 适配层和 Agent 核心是解耦的。Agent 核心不关心你用的是哪家的模型它只关心“我发一个请求你返回一个响应响应里可能有工具调用请求”。这个设计让我可以很方便地在不同模型之间切换做对比测试。我试过同一个任务用不同的模型跑只需要改一个环境变量其他代码完全不用动。工具集的设计也很有意思。Pi 默认提供的工具不多大概就是读文件、写文件、执行 shell 命令、列目录这几个。但它的工具注册机制非常开放你写一个符合接口的对象注册进去agent 就能用了。我自己加了一个“搜索代码库”的工具大概花了二十分钟主要时间花在写搜索逻辑上注册本身只用了两行代码。2.3 为什么选择 TypeScript 而不是 Python这个问题我被问过很多次。Python 在 LLM 生态里确实是主流大部分模型 SDK 都是 Python 优先。但 Pi 选 TypeScript 是有道理的。CLI 工具的发布和分发Node.js 生态比 Python 成熟太多。你写一个 Python CLI 工具用户得先装 Python、再装 pip、再处理虚拟环境、再解决依赖冲突。Node.js 这边一个 npm install -g 就搞定了或者用 npx 直接跑零安装成本。另一个原因是类型系统。Agent 的核心逻辑涉及大量的数据结构转换LLM 返回的 JSON 要解析成工具调用请求工具执行结果要序列化成消息消息要拼装成下一次请求的 payload。这些转换在 TypeScript 里都有类型约束编译期就能发现大部分错误。我用 Python 写类似逻辑的时候经常在运行时才发现某个字段名拼错了或者某个值可能是 None 但没处理。TypeScript 的严格模式能帮你避免很多这类低级错误。当然TypeScript 也有它的坑。比如最近社区里讨论很多的baseUrl选项弃用问题还有moduleResolutionnode10的弃用警告。这些在 TypeScript 7.0 里会有大变化如果你现在开始写新项目最好直接按照新的配置规范来别用那些已经标记弃用的选项。Pi 的 tsconfig 配置我看过用的是比较现代的bundler模块解析策略这个选择在 CLI 工具场景下是合理的因为最终产物会被打包成一个或多个 JS 文件不需要考虑 Node.js 的传统模块解析行为。3. Agent Harness 的核心机制与实操要点3.1 工具调用的完整生命周期理解 Pi 的关键在于理解一次工具调用从发起到完成的完整链路。我把它拆成六个步骤每一步都有需要注意的细节。第一步是用户输入进入对话历史。用户在 CLI 里敲一行字这行字被包装成一条 role 为 user 的消息追加到对话历史数组的末尾。这一步看起来简单但有个细节Pi 不会对用户输入做任何预处理不会自动补全、不会改写、不会加系统提示词。这意味着你输入什么模型就看到什么。好处是行为可预测坏处是你得自己保证输入的清晰度。第二步是构造 LLM 请求。Agent 核心把整个对话历史、系统提示词、可用工具的描述打包成一个请求发给 LLM。这里的关键是工具描述的质量。Pi 的工具描述写得非常简洁比如读文件工具的描述就是“读取指定路径的文件内容”。我一开始觉得太简单了模型能理解吗实测下来对于主流模型这种简洁描述完全够用。反而是一些框架里那种长篇大论的工具描述容易让模型困惑。第三步是解析 LLM 响应。模型返回的响应可能是纯文本也可能是工具调用请求。Pi 需要判断这个响应属于哪种类型。如果是工具调用就提取出工具名和参数如果是纯文本就直接展示给用户。这里有个坑不同模型返回工具调用的格式不一样。有的用 JSON有的用特定的标记语法。Pi 的 LLM 适配层就是干这个的把不同格式统一成内部表示。第四步是执行工具。根据解析出的工具名找到对应的工具对象调用它的 execute 函数传入参数。这一步是实际干活的地方。文件读写、命令执行都发生在这里。Pi 对工具执行没有加超时控制这意味着如果一个 shell 命令卡住了整个 agent 就卡住了。我在实际使用中遇到过这种情况后来自己加了一个超时包装。第五步是处理工具执行结果。工具返回的结果被包装成一条 role 为 tool 的消息追加到对话历史。如果工具执行抛了异常异常信息也会被包装成结果返回给模型。这个设计很重要让模型知道工具失败了它才能决定下一步怎么办。我见过一些框架在工具失败时直接中断整个流程这其实不如把错误信息交给模型处理。第六步是循环。追加完工具结果之后回到第二步再次调用 LLM。模型看到工具的执行结果决定是继续调用工具还是给出最终回答。这个循环会一直持续直到模型返回一个不需要工具调用的响应或者达到某个终止条件。注意这个循环没有硬性的轮次限制。如果你不小心让模型陷入了一个无限调用工具的循环它会一直跑下去。建议在 Agent 核心加一个最大轮次保护比如 50 轮之后强制终止。3.2 系统提示词的设计哲学Pi 的系统提示词非常短短到你可能觉得它没做什么。但正是这种短让它的行为非常可预测。我对比过一些其他 agent 工具的系统提示词动辄几千字里面塞满了各种规则、示例、边界情况处理。Pi 的选择是只告诉模型最基本的行为准则剩下的交给模型自己判断。它的系统提示词大概包含这几个意思你是一个命令行助手你可以使用提供的工具来完成任务在执行危险操作之前先确认保持回答简洁。就这些。没有“你是一个世界级的专家”这种角色设定没有“你必须按照以下格式输出”这种格式约束没有“如果遇到 X 情况就做 Y”这种条件分支。这种设计的好处是模型有更大的自主空间。我试过让 Pi 完成一个需要多步操作的任务比如“找到项目中所有使用了废弃 API 的文件并生成一个报告”。它自己规划了步骤先用搜索工具找到相关文件再用读文件工具逐个检查最后用写文件工具生成报告。整个过程没有我的干预它自己决定用什么工具、按什么顺序。坏处是行为的一致性会差一些。同一个任务不同时间跑模型可能选择不同的工具组合。如果你需要严格可复现的行为这种极简提示词就不太够。我的做法是在系统提示词里加一些项目特定的约束比如“这个项目使用 pnpm 而不是 npm”这样模型在执行命令的时候就会用对包管理器。3.3 工具集的设计与扩展Pi 默认的工具集我前面提过就是文件读写、命令执行、列目录这几个。但它的扩展机制才是真正有价值的地方。我来说说怎么加一个自定义工具以及加的时候要注意什么。假设我要加一个“统计代码行数”的工具。首先定义一个对象const countLinesTool { name: count_lines, description: 统计指定目录下所有代码文件的总行数, parameters: { type: object, properties: { directory: { type: string, description: 要统计的目录路径 }, extensions: { type: array, items: { type: string }, description: 要统计的文件扩展名如 [.ts, .js] } }, required: [directory] }, execute: async (params: { directory: string; extensions?: string[] }) { // 实现统计逻辑 const result await countLines(params.directory, params.extensions); return 总行数: ${result.total}, 文件数: ${result.files}; } };然后把这个对象注册到工具列表里agent 就能用了。这里有几个实操要点。参数 schema 要写清楚。模型是根据 parameters 里的描述来决定传什么参数的。如果你不写 required模型可能不传某些必要参数。如果你不写 description模型可能传错类型。我踩过的坑是一个工具的参数是数组但我没写 items 的类型结果模型有时候传字符串有时候传数组导致执行失败。execute 函数的返回值要是字符串。Pi 会把返回值直接作为工具结果消息的内容。如果你返回一个对象它会被 JSON.stringify但这样模型看到的是一串 JSON不如你直接格式化成可读的文本。我一般会把结果整理成人类可读的格式再返回。错误处理要明确。如果工具执行失败直接抛异常就行Pi 会捕获异常并把错误信息传给模型。但异常信息要写清楚别就写一个“执行失败”要写“文件不存在: /path/to/file”。模型看到具体的错误信息才能决定是重试、换路径、还是放弃。3.4 LLM 适配层的实现细节Pi 的 LLM 适配层是我觉得最值得细看的部分。它要解决的问题是不同 LLM 提供商的 API 格式不一样但 Agent 核心只想要一个统一的接口。适配层的核心是一个函数接收对话历史和工具定义返回一个标准化的响应。这个响应包含两个字段content文本内容和 toolCalls工具调用列表。如果模型返回的是纯文本toolCalls 就是空数组如果模型返回了工具调用content 可能是空的toolCalls 里有内容。实现这个适配层的时候有几个细节要注意。流式响应的处理。很多 LLM API 支持流式返回就是一点一点地吐 token。Pi 的 CLI 界面需要实时显示模型的输出所以适配层要能处理流式数据。我的做法是在适配层里把流式响应拼装成完整响应然后再返回给 Agent 核心。这样 Agent 核心不需要关心流式不流式逻辑更简单。工具调用格式的差异。有的模型返回的工具调用是 JSON 格式有的是特定的标记语法。适配层要能识别这些格式并统一转换。我遇到过一个问题某个模型在返回工具调用的时候会在 JSON 外面包一层 markdown 代码块标记。如果不处理这个JSON.parse 就会失败。适配层里加一个去除代码块标记的逻辑就能解决。错误重试。LLM API 调用可能会失败网络超时、速率限制、服务端错误都有可能。适配层应该实现指数退避的重试逻辑。我一般设置最多重试 3 次第一次等 1 秒第二次等 2 秒第三次等 4 秒。如果三次都失败就把错误抛给上层处理。提示如果你在用某个云服务商的 LLM API注意它的速率限制策略。有的限制是每分钟请求数有的是每分钟 token 数。在适配层里加一个简单的令牌桶限流器可以避免触发限制导致的重试风暴。4. 从零搭建一个 Pi 风格的 Agent Harness4.1 环境准备与项目初始化动手之前先把环境理清楚。你需要 Node.js 18 以上版本我推荐用 20 LTS稳定性最好。包管理器用 pnpm 或者 npm 都行Pi 本身用的是 npm但 pnpm 在依赖管理上更严格一些能避免一些幽灵依赖的问题。初始化项目mkdir my-pi-harness cd my-pi-harness npm init -y npm install typescript tsx types/node --save-dev npx tsc --inittsconfig 的配置很关键。我建议用下面这套{ compilerOptions: { target: ES2022, module: ESNext, moduleResolution: bundler, strict: true, esModuleInterop: true, skipLibCheck: true, outDir: ./dist, rootDir: ./src }, include: [src/**/*] }这里解释几个选择。moduleResolution用bundler而不是node10因为后者已经标记弃用了在 TypeScript 7.0 里会移除。strict一定要开虽然写代码的时候会多处理一些类型检查但能避免很多运行时错误。target用 ES2022 是因为 Node.js 18 以上对 ES2022 的支持已经很完善了不需要降级到更老的版本。项目结构我建议这样组织src/ index.ts # CLI 入口 agent.ts # Agent 核心逻辑 llm/ adapter.ts # LLM 适配层 types.ts # 类型定义 tools/ index.ts # 工具注册 file.ts # 文件操作工具 shell.ts # 命令执行工具 utils/ logger.ts # 日志工具这个结构的好处是职责清晰。你想改 LLM 适配逻辑就去 llm 目录想加工具就去 tools 目录。不会出现一个文件几千行、改一处牵动全身的情况。4.2 Agent 核心逻辑的实现Agent 核心是整个 harness 的心脏。我把它写成一个类大概一百多行代码包含对话历史管理、LLM 调用、工具执行三个主要方法。class Agent { private history: Message[] []; private tools: Tool[]; private llm: LLMAdapter; private maxRounds: number; constructor(config: AgentConfig) { this.tools config.tools; this.llm config.llm; this.maxRounds config.maxRounds ?? 50; this.history.push({ role: system, content: config.systemPrompt }); } async run(userInput: string): Promisestring { this.history.push({ role: user, content: userInput }); for (let round 0; round this.maxRounds; round) { const response await this.llm.chat(this.history, this.tools); if (response.toolCalls.length 0) { this.history.push({ role: assistant, content: response.content }); return response.content; } this.history.push({ role: assistant, content: response.content, toolCalls: response.toolCalls }); for (const call of response.toolCalls) { const result await this.executeTool(call); this.history.push({ role: tool, toolCallId: call.id, content: result }); } } throw new Error(超过最大轮次限制 (${this.maxRounds})); } private async executeTool(call: ToolCall): Promisestring { const tool this.tools.find(t t.name call.name); if (!tool) { return 错误: 未找到工具 ${call.name}; } try { return await tool.execute(call.arguments); } catch (error) { return 错误: ${error instanceof Error ? error.message : String(error)}; } } }这段代码有几个设计决策值得说明。maxRounds 保护是必须的我前面提过没有这个保护模型可能陷入无限循环。工具执行失败不中断流程而是把错误信息作为工具结果返回让模型自己决定怎么办。对话历史包含工具调用信息这样模型在后续轮次里能看到自己之前调用了什么工具、得到了什么结果。我实际跑下来这个核心逻辑能覆盖 90% 以上的 agent 使用场景。剩下的 10% 是一些特殊需求比如并行工具调用、工具调用结果缓存、对话历史压缩。这些都可以在核心逻辑之上做扩展不需要改动核心本身。4.3 工具的具体实现与参数校验工具的实现看起来简单但细节很多。我以文件读取工具为例把关键点都标出来。import { readFile } from fs/promises; import { resolve } from path; export const readFileTool: Tool { name: read_file, description: 读取指定路径的文件内容。支持文本文件返回文件内容字符串。, parameters: { type: object, properties: { path: { type: string, description: 文件的绝对路径或相对于当前工作目录的路径 }, maxLines: { type: number, description: 最多读取的行数默认读取全部内容 } }, required: [path] }, execute: async (params: { path: string; maxLines?: number }) { const absolutePath resolve(process.cwd(), params.path); // 安全检查防止路径穿越 if (!absolutePath.startsWith(process.cwd())) { throw new Error(拒绝访问工作目录之外的文件: ${params.path}); } const content await readFile(absolutePath, utf-8); if (params.maxLines) { const lines content.split(\n); if (lines.length params.maxLines) { return lines.slice(0, params.maxLines).join(\n) \n... (共 ${lines.length} 行已截断); } } return content; } };路径安全检查是我强烈建议加的。如果不加这个检查模型可能被诱导读取系统敏感文件。虽然 Pi 的默认工具集里没有这个检查但在生产环境使用的时候这个防护是必要的。我一般会把工作目录限制在项目根目录任何试图访问上级目录的请求都拒绝。maxLines 参数是为了控制返回内容的大小。如果模型读了一个几千行的文件全部塞进对话历史会消耗大量 token。加一个行数限制让模型可以分次读取或者只读关键部分。我一般设置默认值为 500 行超过就截断并提示。错误信息要具体。文件不存在的时候不要只抛一个“读取失败”要抛“文件不存在: /path/to/file”。模型看到具体路径才能判断是路径写错了还是文件真的不存在。4.4 CLI 交互界面的搭建CLI 界面是用户直接接触的部分体验好坏直接影响使用意愿。Pi 的 CLI 界面很简洁就是一个提示符用户输入然后显示模型输出。但有几个细节让体验好了很多。流式输出。模型生成内容的时候一个字一个字地显示出来而不是等全部生成完再一次性显示。这个体验差异很大。实现流式输出需要在 LLM 适配层支持流式回调然后在 CLI 层把回调的内容实时打印出来。async function chatLoop(agent: Agent) { const rl readline.createInterface({ input: process.stdin, output: process.stdout }); while (true) { const input await new Promisestring(resolve { rl.question( , resolve); }); if (input.trim() exit || input.trim() quit) { break; } if (input.trim() ) { continue; } try { const response await agent.run(input); console.log(\n response \n); } catch (error) { console.error(执行出错:, error); } } rl.close(); }工具调用的可视化。当 agent 调用工具的时候用户应该能看到它在干什么。我一般会在工具执行前后打印一行提示比如“正在读取文件: src/index.ts”。这样用户知道 agent 没有卡住而是在干活。中断处理。用户按 CtrlC 的时候应该优雅地退出而不是抛一堆堆栈信息。加一个 SIGINT 处理器清理资源然后退出。提示如果你想让 CLI 界面更好看可以用 chalk 给输出加颜色用 ora 加加载动画。但注意不要过度Pi 的风格是简洁加太多装饰反而显得花哨。5. 实际使用中遇到的典型问题与排查技巧5.1 模型不调用工具或调用错误工具这是最常见的问题。你明明注册了一个读文件的工具但模型就是不用或者用了错误的工具。排查思路分几步。先看工具描述是否清晰。模型是根据 description 来决定用哪个工具的。如果你的描述写的是“处理文件”模型可能不知道这是读文件还是写文件。改成“读取指定路径的文件内容”意图就明确了。再看参数 schema 是否完整。如果 required 字段没写全模型可能不传某些必要参数导致工具执行失败。如果参数类型没写清楚模型可能传错类型。我遇到过一个情况一个工具的参数是枚举值但我没在 schema 里列出可选值结果模型传了一个不在预期范围内的值。还有一个可能是系统提示词没有提到工具。虽然 Pi 会把工具定义传给模型但如果系统提示词里完全没有提到“你可以使用工具”某些模型可能不会主动调用。在系统提示词里加一句“你可以使用提供的工具来完成任务”能显著提高工具调用率。如果以上都检查了还是不行那就是模型本身的能力问题。不同模型对工具调用的支持程度不一样。我实测下来一些专门针对工具调用优化过的模型在这方面的表现明显更好。如果你用的模型不支持工具调用那 Pi 的整个机制就跑不起来。5.2 工具执行结果过长导致上下文溢出这个问题我在处理大文件的时候经常遇到。模型读了一个几千行的文件结果内容塞进对话历史下一次请求的时候 token 数直接爆了。解决方案有几个。在工具层面做截断就像我前面 readFileTool 里加 maxLines 参数那样。在 Agent 核心做历史压缩当对话历史超过一定 token 数的时候把早期的工具结果替换成摘要。让模型自己控制在系统提示词里告诉模型“读取大文件时使用 maxLines 参数限制行数”。我一般组合使用这几个方案。工具层面默认截断到 500 行Agent 核心在历史超过 80% 上下文窗口的时候触发压缩系统提示词里提醒模型注意文件大小。这样基本不会出现上下文溢出的问题。5.3 命令执行工具的安全隐患命令执行工具是最强大但也最危险的。模型可以执行任意 shell 命令这意味着它可以删除文件、修改系统配置、甚至执行恶意脚本。我的做法是加一个命令白名单。只允许执行特定的命令比如 ls、cat、grep、find、git 这些只读或者安全的命令。任何不在白名单里的命令都拒绝执行。const ALLOWED_COMMANDS [ls, cat, grep, find, git, npm, node]; function isCommandAllowed(command: string): boolean { const baseCommand command.trim().split(/\s/)[0]; return ALLOWED_COMMANDS.includes(baseCommand); }另一个做法是在执行前让用户确认。当模型请求执行一个命令时CLI 界面显示这个命令让用户按 y 确认或者 n 拒绝。这个方案更灵活但会打断自动化流程。我一般是在开发环境用白名单在生产环境用用户确认。注意即使有白名单也要小心命令注入。比如git log; rm -rf /这样的命令baseCommand 是 git在白名单里但分号后面的部分会执行删除操作。所以白名单检查要更严格不能只看第一个词。5.4 常见问题速查表我把实际使用中遇到的问题整理成一个表格方便快速排查问题现象可能原因排查方法解决方案模型不调用工具工具描述不清检查 description 字段改写描述明确工具用途工具调用参数错误schema 不完整检查 parameters 定义补全 required 和类型定义上下文溢出工具结果过长查看对话历史 token 数加截断、压缩历史命令执行被拒绝白名单限制查看命令是否在白名单添加命令或改用确认模式模型输出格式异常适配层解析失败打印原始响应修复解析逻辑处理边界情况请求超时网络或服务端问题查看错误信息加重试逻辑检查网络对话历史丢失进程重启检查是否持久化加历史保存和加载功能这个表格里的每一行都是我实际踩过的坑。特别是“模型输出格式异常”这一条我遇到过好几次。有一次是模型在工具调用的 JSON 外面包了一层 markdown 代码块标记导致 JSON.parse 失败。修复方法就是在解析之前先去掉代码块标记。6. 从 Pi 出发的扩展思路与个人体会Pi 的极简设计让它成为一个很好的起点而不是终点。我在它的基础上做了几个扩展这里分享一下思路。加一个持久化的对话历史。Pi 默认把历史放在内存里进程退出就没了。我加了一个简单的 JSON 文件存储每次对话结束后把历史写到一个文件里下次启动的时候加载回来。这样 agent 就能记住之前的对话适合那种需要跨会话继续的任务。加一个工具调用结果缓存。有些工具调用是幂等的比如读同一个文件内容没变的话没必要重复读。我加了一个简单的缓存层key 是工具名加参数value 是结果。缓存有效期设置得比较短比如 5 分钟避免读到过期的内容。加一个多模型切换机制。Pi 的 LLM 适配层已经解耦了我在此基础上加了一个配置项可以在运行时切换模型。比如简单任务用便宜快速的模型复杂任务用能力更强的模型。这个切换逻辑可以放在 Agent 核心根据任务的复杂度自动选择。我个人在实际操作中的体会是极简 harness 的价值不在于功能多而在于可理解性。当你完全理解了一个 agent 从输入到输出的每一步你就能针对性地优化它。你知道瓶颈在哪知道哪里可以加东西哪里应该保持简单。这种掌控感是使用黑盒框架永远得不到的。最后再分享一个小技巧。如果你在调试 agent 的行为把每次 LLM 请求和响应都完整打印出来。虽然输出会很多但这是理解模型行为最直接的方式。你会看到模型是怎么根据工具描述选择工具的是怎么根据工具结果决定下一步的。看多了之后你对模型的行为模式会有直觉写工具描述和系统提示词的时候就能更有针对性。