Files
lingma-proxy-compose/docs/tool-emulation-methodology.zh-CN.md
coolxll df69105329 docs(tool-emulation): 添加工具调用模拟实现清单与方法论文档
- 创建英文版工具模拟实现清单,涵盖13个核心实现面
- 添加中文版工具模拟实现清单,详细说明各项验收标准
- 编写英文版工具模拟方法论文档,阐述核心实现模式
- 补充中文版方法论文档,包括多轮调用与重试策略指导
- 实现HTTP API服务器测试,验证工具历史保持功能
- 新增工具模拟核心模块,包含工具定义提取与注入功能
- 添加拒绝检测、动作块解析等关键工具模拟组件
2026-03-30 15:35:23 +08:00

379 lines
8.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 纯聊天 API 模拟 Tools 调用的方法论
这份文档总结的是一种通用做法:
- 上游模型只有普通聊天接口
- 不原生支持 `tools` / `tool_calls` / `tool_use`
- 但下游调用方希望继续走 OpenAI 或 Anthropic 风格的工具调用协议
核心思路不是“骗上游说自己支持 tools”而是
1. 在代理层把工具定义改写成一套稳定的提示词契约
2. 让模型用约定的结构化文本输出动作
3. 再由代理把结构化文本还原成标准协议里的 `tool_calls``tool_use`
## 核心原则
### 1. 不依赖上游原生能力
如果上游不支持原生工具调用,最稳的路线不是继续透传 `tools` 字段,而是把工具定义下沉成提示词层协议。
换句话说:
- 对模型来说,它看到的是“你有这些动作,可以按某种格式发起调用”
- 对客户端来说,它看到的仍然是标准 OpenAI / Anthropic 工具协议
代理层负责做两次映射。
### 2. 工具调用必须降维成可解析文本
一个可落地的格式必须满足:
- 模型容易学会
- 人容易读
- 代理容易解析
- 多轮场景里不容易歧义
本项目采用的是 fenced block
```text
```json action
{"tool":"NAME","parameters":{"key":"value"}}
```
```
这个格式比“自然语言里自己说我要调用某个工具”稳定很多。
### 3. 代理是状态机,不只是转发器
一旦进入 emulation 模式,代理就不能再只是简单透传。
它至少要承担这些职责:
- 注入工具说明
- 把历史工具调用改写回上下文
- 把工具结果回灌成下一轮提示
- 识别拒答和跑偏
- 必要时做 retry
- 把文本动作重新编码成标准工具协议
## 一条完整链路
### 输入侧
客户端发来:
- OpenAI `tools` / `tool_choice`
- 或 Anthropic `tools` / `tool_choice`
代理做三件事:
1. 抽取工具名称、描述、参数 schema
2. 归一化 tool choice
3. 判断是否进入 emulation 模式
进入 emulation 后,不再把原始 `tools` 直接交给上游,而是改写系统提示词。
### 提示词侧
提示词里至少要包含:
- 你有工具可用,不要声称“工具不可用”
- 工具列表
- 固定动作格式
- 多轮规则
- `tool_choice` 约束
- 一个有效示例
建议的约束重点:
- 需要工具时必须输出 `json action`
- 独立动作可以一次输出多个 block
- 依赖动作必须等工具结果回来再继续
- 不需要工具时才允许输出普通文本
- 不要解释“为什么不能调用工具”
### 输出侧
模型回复后,代理扫描 `json action` block
- 解析出 `tool`
- 解析出 `parameters`
- 从正文里剥离 action block
然后映射回:
- OpenAI `message.tool_calls`
- Anthropic `content[].tool_use`
如果没有解析到动作,就把剩余文本当普通 assistant 回复。
## 多轮工具调用
这是最容易做坏的部分。
### 单轮模拟并不够
只做第一轮 `tool_calls` 很容易,但这还不是真正的 agent loop。
真正有用的是:
1. 第一轮模型发起工具调用
2. 外部执行工具
3. 把工具结果回灌
4. 模型继续决策
5. 可能再次发起工具调用
6. 或输出最终回答
### 回灌工具结果时,不要只塞原始结果
稳定做法是把工具结果包装成明确的续写指令,而不是只把结果裸塞回去。
例如:
```text
Tool result for call_1:
pong
Based on the tool result above, continue with the next appropriate action using the structured format.
```
这样模型更清楚当前处于“继续 agent loop”的阶段而不是另起一轮普通问答。
### 第二轮不应强依赖重复传 tools
复杂客户端并不一定会在每一轮都重复把 `tools` 发回来。
因此代理应把这些历史也视作“仍处于 emulation 会话中”的信号:
- OpenAI:
- assistant 消息里已有 `tool_calls`
- 后续有 `tool` 角色消息
- Anthropic:
- 历史里已有 `tool_use`
- 后续有 `tool_result`
只要这些历史存在,即使当前轮未重新传 `tools`,代理也应继续以 emulation 方式处理。
### 历史里的工具调用要重新投影成动作文本
模型并不理解 OpenAI / Anthropic 的结构化历史字段。
因此代理要把历史里的:
- `assistant.tool_calls`
- `assistant tool_use`
重新投影成:
```text
```json action
{
"tool": "ping",
"parameters": {
"value": "123"
}
}
```
```
这样模型才能在多轮里看到自己“之前做过什么动作”。
## Few-shot 怎么设计
### 最小 few-shot
至少给一个合法动作示例:
```text
```json action
{
"tool": "read_file",
"parameters": {
"path": "README.md"
}
}
```
```
它的作用不是示范业务逻辑,而是强制模型学会“输出形状”。
### 更稳的 few-shot
如果目标是复杂 agent loop推荐再补一个“工具结果回来后再次决策”的 few-shot。
例如三段式:
1. 用户请求
2. assistant 发起工具调用
3. user 提供 tool result
4. assistant 再次发起新工具调用或结束
这个 few-shot 能显著减少模型在第二轮以后掉回普通文本解释。
### few-shot 要突出状态转换
最重要的不是工具本身,而是让模型明确以下三种状态:
- 该调用工具
- 该等待工具结果
- 该输出最终回答
复杂 loop 不稳,通常就是状态转换没教明白。
## Retry 怎么设计
### Retry 的触发条件
比较实用的触发条件:
- 本轮本应调用工具,但没有解析出 action block
- 模型回复了“没有工具”“工具不可用”“我无法调用”
- `tool_choice=any`
- `tool_choice=tool`
### Retry 的方式
不要只重发原请求。应显式补一条纠偏消息,例如:
```text
Your last response did not include any ```json action``` block.
You must respond with at least one valid action block now.
Do not explain. Output the action block directly.
```
如果是强制指定某个工具,再额外加:
```text
You must call "ping".
```
### Retry 不要无限循环
建议设置:
- 小次数重试
- 每次 retry 都更强约束
- 只在明确需要工具调用时触发
否则很容易把普通自然回复误判成失败。
## 协议映射建议
### OpenAI
输入:
- `tools`
- `tool_choice`
- `assistant.tool_calls`
- `tool`
输出:
- `finish_reason = "tool_calls"`
- `message.tool_calls`
### Anthropic
输入:
- `tools`
- `tool_choice`
- `content[].tool_use`
- `content[].tool_result`
输出:
- `stop_reason = "tool_use"`
- `content[].tool_use`
流式时,再映射成对应的 SSE 事件。
## 常见坑
### 1. 只做第一轮
这会让你看起来“支持 tools”但一进入 agent loop 就断掉。
### 2. 历史工具调用没有重投影
模型看不到自己的历史动作,多轮就不稳。
### 3. 工具结果回灌过于裸
只把 `pong` 塞回去,模型不一定知道自己该继续决策。
### 4. 没有 refusal 检测
很多模型会下意识说:
- 我没有工具
- 当前环境无法调用
- 我只能提供建议
不识别这类模式,就不会进入纠偏 retry。
### 5. 文本解析规则太脆弱
解析器至少要容忍:
- ` ```json action ` 或普通 ` ```json `
- 智能引号
- 末尾逗号
- 参数对象有时是字符串化 JSON
## 推荐的最小实现
如果要做一个最小可用版,建议先只做:
1. 工具定义注入
2. `json action` 解析
3. refusal 检测
4. 一次 retry
5. OpenAI 非流式返回
然后再逐步补:
1. Anthropic 非流式
2. OpenAI 流式
3. Anthropic 流式
4. 多轮 tool history 投影
5. 更强 few-shot
## 适用边界
这套方法适合:
- 上游不支持原生 tools
- 你又必须对外兼容标准工具协议
- 目标任务以工程类、文件类、检索类工具为主
它不适合:
- 对工具调用正确率极高要求的强生产场景
- 上游已经支持原生 tools但你还硬要绕一层文本模拟
如果上游能原生支持工具调用,优先使用原生协议。
## 本项目里的落地经验
`lingma-ipc-proxy` 里,这套方法最终证明了两点:
1. 只靠透传 `tools` 给 Lingma 不够,模型会继续说“没有可用工具”
2. 代理层做 emulation 后,可以稳定还原出:
- OpenAI `tool_calls`
- Anthropic `tool_use`
- 多轮 tool result 回灌后的继续决策
进一步要增强稳定性,最值得继续打磨的是:
- 多轮再次发起新工具调用的 few-shot
- 基于历史状态的更细 retry 策略
- 不同工具类别的专用示例
配套实现清单:
- [tool-emulation-checklist.zh-CN.md](./tool-emulation-checklist.zh-CN.md)