尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Pi 极简 Agent harness 实战:TypeScript 构建与核心机制解析

Pi 极简 Agent harness 实战:TypeScript 构建与核心机制解析 1. 先搞清楚 Pi 到底是个什么东西第一次看到“Pi10w stars 的极简 Agent harness”这个标题我脑子里冒出来的第一个念头是又一个套壳 Agent 框架毕竟这两年 LLM 相关的轮子实在太多了光 GitHub 上叫得上名字的 Agent 框架没有一百也有八十。但当我真正把 Pi 的源码拉下来读了一遍之后发现它跟市面上大多数“大而全”的框架走的是完全相反的路线——它把“极简”这两个字做到了近乎偏执的程度。Pi 是一个用 TypeScript 写的 Agent harness核心代码量非常小小到你可以在一个下午读完所有源码。它的定位不是要替代 LangChain 或者 AutoGPT 那种全家桶而是提供一个最小可用的 Agent 运行时骨架让你能够清楚地看到“一个 Agent 从接收输入到产出结果中间到底发生了什么”。这一点对于想真正理解 Agent 工作原理的开发者来说价值非常大。那什么是 harness这个词在软件工程里原本指的是“测试夹具”或者“运行框架”放到 Agent 语境下它指的是包裹在 LLM 外面、负责编排工具调用、管理对话状态、处理循环控制的那一层代码。你可以把 LLM 想象成一个很聪明但只会聊天的顾问harness 就是那个帮顾问接电话、整理资料、跑腿办事的助理。没有 harnessLLM 只能跟你一问一答有了 harnessLLM 才能自己决定去查资料、调接口、写文件、跑命令。Pi 解决的痛点很明确市面上的 Agent 框架要么太重依赖一大堆配置复杂调试困难要么太黑盒你不知道它内部到底怎么调度工具、怎么管理上下文。Pi 的做法是把所有非核心的东西全部砍掉只保留最关键的几个抽象消息、工具、循环。你拿到手之后可以很清楚地看到每一步在干什么想改哪里就改哪里想加什么就加什么。这个项目适合谁如果你已经会用 TypeScript对 LLM API 调用有基本了解但一直觉得 Agent 框架像个黑盒想自己动手搞明白里面的门道那 Pi 非常适合你。如果你只是想快速搭一个能用的 Agent 产品那 Pi 可能不是最优选择因为它太底层了很多工程化的东西需要你自己补。但如果你想真正吃透 Agent 的运行机制Pi 是目前我见过的最好的学习材料之一。2. 为什么是 TypeScript为什么是极简2.1 TypeScript 在这个场景下的真实优势很多人一提到 Agent 框架就想到 Python毕竟 LangChain、LlamaIndex 这些主流工具都是 Python 生态的。Pi 选择 TypeScript 作为实现语言这个决策背后有很实际的考量。第一类型系统对工具调用的约束非常关键。Agent 的核心工作之一就是让 LLM 输出结构化的工具调用请求然后 harness 解析这个请求并执行对应的函数。在 Python 里你通常用 Pydantic 或者 JSON Schema 来做校验但这些都是在运行时才生效的。TypeScript 的类型系统可以在编译期就帮你发现工具定义和实际实现之间的不一致这在工具数量多起来之后能省掉大量调试时间。第二流式处理是 TypeScript 的强项。LLM 的输出通常是流式的你需要一边接收 token 一边处理。TypeScript 的 async iterator 和 stream 处理能力非常成熟配合 Node.js 的 ReadableStream写起来很顺手。而且前端如果也要做流式展示前后端可以共用同一套类型定义不用来回转换。第三生态兼容性好。Pi 作为一个 harness需要跟各种 LLM 提供商的 SDK 打交道。OpenAI、Anthropic 这些主流厂商都有官方或社区维护的 TypeScript SDK质量都不错。而且 TypeScript 项目可以很方便地打包成 npm 包别人用起来门槛低。当然TypeScript 也有它的代价。类型体操写多了确实费脑子编译配置有时候也挺折腾。但 Pi 的极简哲学在这里帮了大忙——它没有搞一堆复杂的泛型和抽象类型定义都很直白读起来不费劲。2.2 极简架构的取舍逻辑Pi 的极简不是“功能少”而是“抽象层次少”。我数了一下它的核心概念大概只有这么几个Message对话消息包括用户输入、LLM 回复、工具调用请求、工具执行结果Tool工具定义包括名称、描述、参数 schema、执行函数Agent编排器负责管理消息历史、调用 LLM、解析工具调用、执行工具、把结果塞回消息历史ProviderLLM 提供商的适配层把不同厂商的 API 统一成同一个接口就这些。没有 Chain没有 Memory 抽象没有 Retriever没有 Vector Store。这些东西在 Pi 里要么不存在要么就是几行代码的事。这种极简带来的好处是可调试性极强。当 Agent 行为不符合预期时你可以很清楚地定位到是哪一步出了问题是 LLM 没理解工具描述是工具参数解析错了还是循环控制逻辑有 bug因为整个流程是线性的、透明的没有层层嵌套的抽象来干扰你的判断。代价也很明显你需要自己处理很多边界情况。比如上下文窗口管理Pi 只提供了最基础的消息裁剪策略更复杂的摘要、压缩、检索增强都需要你自己实现。再比如错误重试Pi 只做了最基本的重试逻辑更精细的退避策略、熔断机制都要自己加。但话说回来这些“缺失”恰恰是 Pi 作为学习工具的价值所在——它逼着你去思考这些问题该怎么解决。3. 核心机制拆解Agent 循环到底怎么跑3.1 一次完整的 Agent 调用经历了什么要理解 Pi最关键的是理解它的主循环。我用一个实际场景来串一遍假设你让 Agent “帮我查一下今天北京的天气然后根据天气推荐穿什么衣服”。第一步用户消息进入消息队列。Pi 把这条消息包装成一个标准的 Message 对象role 是 usercontent 是文本。第二步Agent 把当前的消息历史包括系统提示词发给 LLM。系统提示词里会包含所有可用工具的描述比如get_weather和recommend_clothing。第三步LLM 返回一个响应。这个响应可能是纯文本也可能包含工具调用请求。在这个场景下LLM 大概率会先请求调用get_weather参数是{city: 北京}。第四步Pi 解析这个工具调用请求找到对应的工具定义执行get_weather函数。执行结果比如{temperature: 28, condition: 晴}被包装成一条 tool result 消息追加到消息历史里。第五步Agent 再次调用 LLM这次消息历史里多了工具执行结果。LLM 看到天气数据后可能会请求调用recommend_clothing参数是{temperature: 28, condition: 晴}。第六步Pi 执行recommend_clothing拿到推荐结果再次追加到消息历史。第七步Agent 第三次调用 LLM。这次 LLM 觉得信息够了直接返回一段文本“北京今天晴气温 28 度建议穿短袖 T 恤和短裤注意防晒。”第八步Pi 检测到 LLM 返回的是纯文本而非工具调用循环结束把最终结果返回给用户。整个过程就是一个while 循环只要 LLM 还在请求工具调用就继续执行工具、追加结果、再次调用 LLM直到 LLM 返回纯文本或者达到最大迭代次数。3.2 工具定义的设计细节Pi 的工具定义接口设计得很克制。一个工具需要提供这些东西interface Tool { name: string; description: string; parameters: JSONSchema; execute: (args: any) PromiseToolResult; }name是工具的唯一标识LLM 在请求调用时会用这个名字。description是给 LLM 看的自然语言说明写得越清楚LLM 越不容易用错。parameters是 JSON Schema 格式的参数定义LLM 会根据这个 schema 来生成参数。execute是实际执行函数接收解析后的参数返回执行结果。这里有几个容易踩坑的地方。description 的写法直接影响工具调用准确率。我试过把 description 写得太简短结果 LLM 经常搞混相似的工具。后来改成“这个工具用于查询指定城市的实时天气状况返回温度和天气描述。注意只能查询中国城市不支持国外城市”准确率明显提升。parameters 的 schema 要尽量严格。比如一个参数如果是枚举类型一定要用enum限定取值范围不要让 LLM 自由发挥。我见过太多因为参数格式不对导致工具执行失败的案例大部分都可以通过收紧 schema 来避免。execute 函数要做好错误处理。工具执行失败是常态网络超时、API 限流、参数不合法都会导致失败。Pi 会把执行失败的信息也作为 tool result 返回给 LLM让 LLM 决定是重试还是换一种方式。所以你的错误信息要写得对 LLM 友好比如“城市名称无效请检查后重试”就比“Error: invalid city”要好得多。3.3 消息历史管理的策略Pi 对消息历史的管理非常朴素就是一个数组每次 LLM 调用和工具执行都会往里面追加消息。但这里有一个关键问题上下文窗口是有限的。当消息历史越来越长迟早会超出 LLM 的上下文窗口限制。Pi 提供了几种基础的裁剪策略按条数裁剪保留最近 N 条消息丢弃更早的按 token 数裁剪估算消息历史的 token 总量超出阈值就丢弃最早的消息保留系统提示词无论怎么裁剪系统提示词永远保留这些策略都很简单但实际用起来需要根据场景调整。比如在长对话场景下简单丢弃早期消息会导致 Agent “失忆”忘记之前讨论过的关键信息。这时候就需要更复杂的策略比如把早期消息做摘要后再保留或者用向量检索的方式按需召回相关历史。Pi 没有内置这些高级策略但它的消息历史就是一个普通数组你可以很方便地在调用 LLM 之前对数组做任何处理。这种“不替你做决定”的设计哲学我觉得是 Pi 最聪明的地方之一。4. 从零搭一个 Pi Agent 的完整实操4.1 环境准备与依赖安装先把基础环境搭起来。你需要 Node.js 18 以上版本因为 Pi 用到了原生的 fetch 和 ReadableStream。TypeScript 版本建议 5.0 以上低版本在类型推断上会有一些问题。mkdir pi-agent-demo cd pi-agent-demo npm init -y npm install typescript tsx types/node --save-dev npm install pi/agent --save如果你用的是 pnpm 或者 yarn把 npm 换成对应的命令就行。tsx是用来直接运行 TypeScript 文件的省去编译步骤开发阶段很方便。然后初始化 TypeScript 配置npx tsc --init生成的tsconfig.json需要改几个地方。target设为ES2022module设为NodeNextmoduleResolution设为NodeNextstrict设为true。这些配置能保证你用到最新的语言特性同时类型检查足够严格。注意如果你在项目里同时用了其他依赖可能会遇到 TypeScript 版本冲突的问题。我遇到过vue-tsc要求 TypeScript 5.3 而 Pi 要求 5.4 的情况最后是通过在根目录锁定 TypeScript 版本解决的。建议在package.json里把 TypeScript 版本写死不要用^范围。4.2 定义你的第一个工具我们从一个最简单的工具开始获取当前时间。虽然这个功能 LLM 自己也能做但作为演示足够了。import { Tool } from pi/agent; const getCurrentTime: Tool { name: get_current_time, description: 获取当前日期和时间。当用户询问现在几点、今天几号时使用此工具。, parameters: { type: object, properties: { timezone: { type: string, description: 时区例如 Asia/Shanghai。默认为 Asia/Shanghai。, enum: [Asia/Shanghai, America/New_York, Europe/London] } }, required: [] }, execute: async (args) { const timezone args.timezone || Asia/Shanghai; const now new Date(); const formatted now.toLocaleString(zh-CN, { timeZone: timezone }); return { success: true, data: { time: formatted, timezone } }; } };这个工具定义里有几个细节值得说。description里明确写了“当用户询问现在几点、今天几号时使用此工具”这是给 LLM 的使用指引。parameters里timezone用了enum限定取值范围防止 LLM 传入无效时区。execute函数返回了一个结构化对象包含success和data字段这样 LLM 能清楚地知道执行是否成功。4.3 组装 Agent 并跑起来有了工具之后就可以创建 Agent 实例了import { Agent, OpenAIProvider } from pi/agent; const provider new OpenAIProvider({ apiKey: process.env.OPENAI_API_KEY, model: gpt-4o-mini }); const agent new Agent({ provider, tools: [getCurrentTime], systemPrompt: 你是一个乐于助人的助手。回答用户问题时如果需要获取实时信息请调用相应的工具。, maxIterations: 10 }); const result await agent.run(现在北京几点); console.log(result.content);跑起来之后你会看到 Agent 先调用get_current_time工具拿到时间后再生成自然语言回复。整个过程在控制台里可以清楚地看到每一步的消息流转。实操心得开发阶段建议把maxIterations设小一点比如 5。这样当 Agent 陷入死循环时能快速失败方便你排查问题。等调试稳定了再调大。4.4 接入真实的 LLM 提供商Pi 的 Provider 层设计得很薄基本上就是把你选的 LLM 厂商的 SDK 包一层统一成chat接口。以 OpenAI 为例核心就是调chat.completions.create把消息历史传进去把工具定义转成 OpenAI 的 function calling 格式。如果你要用其他厂商比如 Anthropic 或者国内的模型服务需要自己写一个 Provider 适配器。适配器的核心工作就两件把 Pi 的 Message 格式转成厂商 API 要求的格式把厂商返回的响应转回 Pi 的格式。大部分厂商的 API 结构都差不多写起来不复杂。这里有一个容易忽略的点不同厂商对工具调用的支持程度不一样。有些模型对 function calling 的支持很好能准确生成参数有些模型则经常生成格式错误的 JSON。Pi 在解析工具调用时会做容错处理但如果模型本身能力不行再好的 harness 也救不了。所以选模型的时候要实际测试一下工具调用的准确率。5. 实际使用中踩过的坑和排查方法5.1 工具调用不触发或者触发错误这是最常见的问题。Agent 该调工具的时候不调或者调了错误的工具。排查思路是这样的先看系统提示词里有没有明确告诉 LLM 可以用工具。有些模型需要你在提示词里显式地说“你可以使用以下工具”否则它会忽略工具定义。然后在工具描述里检查有没有歧义。如果两个工具的功能有重叠LLM 很容易搞混。解决办法是把描述写得更具体明确区分各自的适用场景。还有一个隐蔽的问题工具名称的命名风格。我试过用驼峰命名getCurrentTime结果某些模型在生成调用请求时会把它转成下划线风格get_current_time导致找不到工具。后来统一改成下划线风格就没这个问题了。问题现象可能原因排查方法完全不调工具系统提示词未提及工具在提示词中明确说明可用工具调错工具工具描述有歧义检查描述是否清晰区分了各工具参数格式错误schema 不够严格收紧 schema用 enum 限定取值范围工具名不匹配命名风格不一致统一使用下划线命名5.2 循环停不下来Agent 一直在调工具永远不返回最终结果。这种情况通常是 LLM 陷入了“工具调用-结果不满足-再调用”的循环。比如你让它查天气它查到了但觉得数据不对又查一遍反复循环。Pi 的maxIterations参数就是用来兜底的。但更好的做法是在工具执行结果里给 LLM 明确的信号。比如当工具执行成功时在结果里加上status: complete并在系统提示词里告诉 LLM“如果工具返回 status 为 complete说明信息已经足够请直接生成最终回复”。另一个技巧是在工具描述里写明使用次数限制。比如“此工具每次对话最多调用一次”LLM 看到这个说明后会更有节制。5.3 上下文窗口溢出长对话场景下消息历史会越来越长最终超出模型的上下文窗口。Pi 默认的裁剪策略是按条数保留最近的消息但这会导致早期的重要信息丢失。我的做法是在 Agent 外面包一层消息管理逻辑每次调用 LLM 之前先估算当前消息历史的 token 数如果接近阈值就把最早的一批消息做摘要用摘要替换原始消息。摘要可以用一个便宜的模型来生成成本很低。还有一个更简单的策略把关键信息写进系统提示词。比如用户的偏好、当前任务的目标这些信息不随对话轮次变化放在系统提示词里就不会被裁剪掉。5.4 工具执行超时或报错外部 API 调用失败是常态。Pi 会把工具执行失败的信息返回给 LLM但如果你不做处理LLM 可能会反复重试同一个失败的工具。我的经验是在工具执行函数里加超时控制和重试逻辑。超时时间根据工具类型来定查询类工具 5 秒够了生成类工具可能需要 30 秒。重试次数不要超过 2 次否则会拖慢整个 Agent 的响应速度。注意工具执行失败时返回给 LLM 的错误信息要具体。比如“天气 API 返回 429请求过于频繁请稍后重试”就比“请求失败”有用得多。LLM 看到具体的错误原因后能更好地决定下一步怎么做。6. 关于 Pi 的一些个人体会用 Pi 做了一段时间的项目之后我最大的感受是Agent 的复杂度不在于框架本身而在于你对业务场景的理解。Pi 把框架层面的东西简化到了极致剩下的就是你要想清楚这个场景下需要哪些工具工具之间的调用顺序是什么怎么判断任务完成了这些问题没有标准答案只能根据具体场景来设计。Pi 的另一个价值是它让你对 Agent 的预期更理性。很多演示视频里 Agent 看起来无所不能但实际用起来你会发现LLM 在工具调用上的准确率远没有达到可以完全放手的程度。你需要设计各种兜底逻辑、错误处理、人工确认环节。Pi 的透明性让你能清楚地看到问题出在哪里而不是被框架的抽象层掩盖了真相。如果你正在选型 Agent 框架我的建议是先用 Pi 这样的极简框架把核心流程跑通理解清楚 Agent 的工作原理和瓶颈所在。等你对这些问题有了切身体会之后再根据实际需求决定是继续在 Pi 上扩展还是换用更重量级的框架。直接上手大框架很容易陷入“配置了一堆东西但不知道为什么要这么配”的困境。最后分享一个我在实际项目中用到的小技巧给每个工具加一个dryRun模式。在开发调试阶段工具不真正执行外部调用而是返回模拟数据。这样你可以快速验证 Agent 的调用逻辑是否正确不用每次都等真实 API 返回。等逻辑调通了再关掉dryRun切换到真实执行。这个模式在 Pi 里实现起来很简单就是在execute函数开头加一个判断返回预设的模拟数据即可。
返回列表