news 2026/9/26 17:01:30

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Pi 极简 Agent harness 实战:TypeScript 构建与核心机制解析

1. 先搞清楚 Pi 到底是个什么东西

第一次看到“Pi:10w stars 的极简 Agent harness”这个标题,我脑子里冒出来的第一个念头是:又一个套壳 Agent 框架?毕竟这两年 LLM 相关的轮子实在太多了,光 GitHub 上叫得上名字的 Agent 框架没有一百也有八十。但当我真正把 Pi 的源码拉下来读了一遍之后,发现它跟市面上大多数“大而全”的框架走的是完全相反的路线——它把“极简”这两个字做到了近乎偏执的程度。

Pi 是一个用 TypeScript 写的 Agent harness,核心代码量非常小,小到你可以在一个下午读完所有源码。它的定位不是要替代 LangChain 或者 AutoGPT 那种全家桶,而是提供一个最小可用的 Agent 运行时骨架,让你能够清楚地看到“一个 Agent 从接收输入到产出结果,中间到底发生了什么”。这一点对于想真正理解 Agent 工作原理的开发者来说,价值非常大。

那什么是 harness?这个词在软件工程里原本指的是“测试夹具”或者“运行框架”,放到 Agent 语境下,它指的是包裹在 LLM 外面、负责编排工具调用、管理对话状态、处理循环控制的那一层代码。你可以把 LLM 想象成一个很聪明但只会聊天的顾问,harness 就是那个帮顾问接电话、整理资料、跑腿办事的助理。没有 harness,LLM 只能跟你一问一答;有了 harness,LLM 才能自己决定去查资料、调接口、写文件、跑命令。

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、解析工具调用、执行工具、把结果塞回消息历史
  • Provider:LLM 提供商的适配层,把不同厂商的 API 统一成同一个接口

就这些。没有 Chain,没有 Memory 抽象,没有 Retriever,没有 Vector Store。这些东西在 Pi 里要么不存在,要么就是几行代码的事。

这种极简带来的好处是可调试性极强。当 Agent 行为不符合预期时,你可以很清楚地定位到是哪一步出了问题:是 LLM 没理解工具描述?是工具参数解析错了?还是循环控制逻辑有 bug?因为整个流程是线性的、透明的,没有层层嵌套的抽象来干扰你的判断。

代价也很明显:你需要自己处理很多边界情况。比如上下文窗口管理,Pi 只提供了最基础的消息裁剪策略,更复杂的摘要、压缩、检索增强都需要你自己实现。再比如错误重试,Pi 只做了最基本的重试逻辑,更精细的退避策略、熔断机制都要自己加。但话说回来,这些“缺失”恰恰是 Pi 作为学习工具的价值所在——它逼着你去思考这些问题该怎么解决。

3. 核心机制拆解:Agent 循环到底怎么跑

3.1 一次完整的 Agent 调用经历了什么

要理解 Pi,最关键的是理解它的主循环。我用一个实际场景来串一遍:假设你让 Agent “帮我查一下今天北京的天气,然后根据天气推荐穿什么衣服”。

第一步,用户消息进入消息队列。Pi 把这条消息包装成一个标准的 Message 对象,role 是 user,content 是文本。

第二步,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) => Promise<ToolResult>; }

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设为ES2022,module设为NodeNext,moduleResolution设为NodeNext,strict设为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函数开头加一个判断,返回预设的模拟数据即可。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 17:01:05

JavaScript性能优化实战:从卡顿定位到长任务拆解与内存泄漏排查

说实话&#xff0c;JavaScript性能优化这块&#xff0c;很多人一开始都走偏了。我记得有个朋友又松拿着他刚改完的项目来找我&#xff0c;页面加载3秒多、滚动卡成PPT、手机上一操作就白屏&#xff0c;他第一反应是加服务器、换框架、上微前端&#xff0c;结果折腾一圈毫无起色…

作者头像 李华
网站建设 2026/9/26 17:01:05

Claude Code token消耗监控与省钱指南:从日志到网关的四种统计方案

1. 为什么Claude Code像“吞金兽”&#xff1a;先搞懂token都消耗在哪些环节1.1 一次看似普通的对话&#xff0c;到底烧掉了多少令牌很多同学对token的认知是“我发一句话&#xff0c;模型回一句话&#xff0c;按两边的字数算钱”。在实际用Claude Code之前&#xff0c;我也是这…

作者头像 李华
网站建设 2026/9/26 17:00:59

【研发类-前端开发Skills】avalonia-viewmodels-zafiro 技能

使用Zafiro和ReactiveUI的Avalonia最佳ViewModel和向导创建模式。技能概述avalonia-viewmodels-zafiro 技能提供一套最佳实践和模式&#xff0c;用于在Avalonia应用程序中创建ViewModel、向导和管理导航&#xff0c;利用ReactiveUI和Zafiro工具包的强大功能。下载地址&#xff…

作者头像 李华
网站建设 2026/9/26 17:00:48

小团队自建永久在线CRM:Flask+PostgreSQL+Nginx实战

1. 为什么小团队需要一个"永久在线"的CRM做过小团队管理的人都有一个共同体会&#xff1a;客户信息散落在微信聊天记录、Excel表格、个人手机通讯录里&#xff0c;销售一走&#xff0c;客户跟着走。市面上成熟的SaaS CRM按人头收费&#xff0c;五个人一年下来少说几千…

作者头像 李华
网站建设 2026/9/26 16:57:15

Windows窗口自动排布工具:支持复合筛选与事件驱动的编排系统

1. 这不是普通窗口管理器&#xff1a;它专为“多任务并行”而生你有没有过这样的时刻&#xff1a;开着3个浏览器窗口查资料、2个Excel表格核对数据、1个微信窗口同步沟通、后台还挂着远程桌面和监控看板——结果一晃神&#xff0c;某个关键窗口被盖在最底下&#xff0c;找它得挨…

作者头像 李华
网站建设 2026/9/26 16:56:30

HIS系统部署与二次开发实战:从数据库初始化到挂号收费主链路

简介&#xff1a;一套面向小型诊所和医疗机构的轻量级HIS&#xff08;医院信息系统&#xff09;源码包&#xff0c;基于ASP.NET Web技术构建&#xff0c;覆盖病患管理、挂号、药品、收费、统计报表、医生排班和患者追踪等核心模块。压缩包共451个文件&#xff0c;约7.05MB&…

作者头像 李华