hello 我是逆境不可逃
上一篇梳理了 Pi 的工具调用循环:模型提出动作,程序执行工具,把结果交回模型,再决定下一步。
顺着这个过程,会遇到一个问题:换一个模型服务之后,工具请求的格式变了,这套循环还能继续用吗?
Pi 把模型接口的转换工作放在 pi-ai 中。理解这一层,可以先从一个具体动作开始:读取 README.md。
同一个动作,为什么有不同格式
不同模型接口可以用不同结构表达工具请求。以 Pi 接入的两种接口为例,只保留普通函数工具调用的核心字段:
| 信息 | OpenAI Chat Completions 格式 | Anthropic Messages 格式 |
|---|---|---|
| 工具名称 | 放在 function.name 中 | 放在 name 中 |
| 工具参数 | 放在 function.arguments 中,是 JSON 字符串 | 放在 input 中,是对象 |
| 调用编号 | id | id |
JSON 字符串是一段需要解析的文本,对象则是已经能按字段取值的数据。两种表示都可以表达"读取 README.md"。
如果在 Agent 循环里分别读取这些格式,每增加一种接口,就可能多出一组判断。工具执行、消息记录也容易跟着依赖某个接口的结构。
Pi 在适配层完成转换,上层使用统一的工具请求:
text
调用编号:call_A
工具名称:read
工具参数:路径 = README.md
Agent 得到这份请求后,可以按原来的规则找到工具、检查参数并执行。字段来自哪种外部格式,由适配层处理。
这里的统一针对接口协议。一家模型服务可能提供多种接口,多家服务也可能使用同一种兼容格式,因此"接入一家服务"和"新增一套协议转换"不一定是同一件事。
适配要做两个方向
请求模型时,Pi 需要把内部的对话记录、系统指令和工具说明转换成目标接口接受的格式。收到响应后,又要把外部数据整理成内部消息和事件。
text
请求方向:
Pi 的对话记录和工具说明 → 接口适配 → 模型服务
响应方向:
模型服务 → 接口适配 → Pi 的统一消息 → Agent 循环
只统一请求还不够。如果返回的数据仍然保持各家格式,Agent 每次收到响应时,依旧要分别判断哪里是文本、哪里是工具请求。
Pi 的内部消息可以表示文本、思考内容和工具调用。工具执行结果也有自己的消息结构。适配层负责把这些结构转换成目标接口的表达方式。
转换必须保留动作的含义。字段从 input 变成 arguments 没有问题,但路径从 README.md 变成 package.json,就已经改变了任务。即使后面成功读到了文件,这次转换仍然是错的。
调用编号为什么不能丢
假设模型一次请求读取两个文件:
text
请求 A:read,路径 = README.md
请求 B:read,路径 = package.json
两个请求的工具名相同,完成顺序也可能不同。程序需要知道每份结果属于哪次请求,不能只凭工具名称或完成顺序配对。
Pi 的工具请求带有调用编号,工具结果则记录自己对应的调用编号。下一轮请求模型时,这个关系会一起提交。
如果把 README.md 的内容标成请求 B 的结果,模型就可能把它当作 package.json 的内容。文件读取本身没有失败,错误发生在请求和结果的对应关系上。
切换模型接口时,编号还可能需要转换。不同接口对编号的字符和长度有不同要求。Pi 会记录旧编号到新编号的映射,并同步调整对应结果中的编号。
text
转换前:请求编号 A,结果对应 A
转换后:请求编号 A_new,结果也必须对应 A_new
所以需要保持的是配对关系,编号文本未必原样不动。
调用编号也不等于工单编号。模型可以请求"创建工单",实际创建成功后,工单系统再返回真实工单编号。至于防止重复创建所需的业务幂等编号,则需要业务服务实现去重逻辑,不能靠工具调用编号自动获得。
流式参数要等到什么时候
工具参数也可能分段到达。下面是一个简化示例:
text
第 1 段:{"path":
第 2 段:"README
第 3 段:.md"}
合起来才是完整的读取请求。适配层会积累片段,更新当前工具调用的内容,并发出进度事件。
Pi 可以在生成过程中解析部分参数,供上层观察进度。不过,当前 Agent 循环会先等待完整的模型响应,再进入工具调度。某个参数已经能读出来,并不意味着应该立即执行。
假设一个读取工具的起始行默认是第 1 行。生成中途只有路径,随后模型才补上"从第 1000 行开始"。提前执行虽然可能成功,却没有执行模型最终表达的动作。
这里有两个不同的检查:生成是否已经结束,以及最终参数是否符合工具要求。前者不能由"这段 JSON 能解析"代替,后者也不能由"模型已经生成完"代替。
Pi 还处理了输出长度上限这个边界:如果模型响应因为长度限制被截断,其中的工具参数可能不完整。当前循环会把这批工具调用转成错误结果,而不会实际执行它们。
模型请求失败和工具失败,分别处理
两种错误发生在不同阶段:
text
模型请求失败:网络断开,没拿到完整响应
工具执行失败:模型请求完整,但读取的文件不存在
模型流以错误或取消状态结束时,当前 Agent 循环会先处理本轮结束,不执行这条失败响应中的工具调用。
如果读取工具已经执行,只是文件不存在,工具会返回错误结果。这条结果仍然可以交给模型,供它检查目录、修改路径,或者解释为什么无法继续。
这一区分也影响重试。模型请求失败,需要考虑重新请求模型;文件不存在,则应先处理路径问题。没有区分失败发生的位置,盲目重试可能只会重复同一个错误。
如果工具有写入副作用,还要额外考虑重复执行。例如创建工单后响应丢失,调用方看到的是超时,但服务端可能已经创建成功。重试同一次业务操作时,需要沿用业务幂等编号,并由服务端识别重复请求。这属于工具背后的业务设计。
统一格式,也要处理能力差异
统一消息结构能携带图片,不代表所有模型都能看图。Pi 的模型信息中会记录支持的输入类型,消息转换也会参考这个信息。
在当前实现中,目标模型不支持图片时,转换逻辑会把图片替换成文字占位标记,说明图片因为模型不支持而被省略。用户图片和工具返回的图片都有对应处理。
这能让后续模型知道原来存在一张图片,但它仍然看不到图片内容。假设用户只发了一张报错截图,再问"怎么解决",剩下的文字不足以回答原问题。
从产品设计上,可以在发起请求前提示用户切换模型,或采用明确告知的文字识别方案。无论采用哪种方式,都需要承认转换后的信息发生了变化。接口适配不能凭空补齐模型能力。
怎样检查适配是否正确
验证时,可以围绕一次文件读取检查具体行为:
- 工具名称和参数含义是否保留,路径有没有变化。
- 请求与结果是否正确配对,编号转换后是否仍然一致。
- 流式片段是否正确合并,初始内容有没有被后续增量覆盖或丢失。
- 模型响应失败或被截断时,是否避免执行不完整的工具请求。
这些检查不必全部依赖真实模型。用预设响应模拟文本增量、工具参数和错误结束,可以更稳定地重现边界情况。
本地复核使用了仓库已有的流式解析与跨接口消息转换测试。其中,流式测试检查初始内容与增量的合并、工具参数解析和错误信息保留;消息转换测试检查跨模型历史的处理,以及如何为缺少结果的调用补上明确的错误消息。
例如,一条历史记录有工具请求,却没有对应结果,Pi 的消息转换会补充"没有提供结果"的错误消息。它补齐的是协议需要的对应关系,并没有编造一次成功执行。
把这些细节放回工具循环里,就能看清适配层的作用:Agent 得到含义明确、关系完整的消息,才能正确调度工具并决定下一步。换一种编程语言实现,同样需要处理格式转换、流式状态、调用关联和能力差异。
