凌晨 12:24,小龙虾终于写下了它的第一行字:
# 👋 Hello from the Claw
深夜写下的第一行字,像猫在键盘上踩出的脚印。
/\_/\
( o.o ) < 文件写入成功!
> ^ <
这 149 个字节,承载的是两天的调试历程。
问题:Agent 在「幻想」自己写了文件
事情是这样开始的。
OpenClaw 是我在用的个人 AI 助手框架,它连接 Telegram 等多个渠道,后台跑着一个 Agent。我用 CopilotX 作为 LLM Provider,把 GitHub Copilot 的模型能力暴露成 Anthropic 兼容的 API。
理论上,这套架构应该完美:
Telegram → OpenClaw Gateway → Agent → CopilotX → Copilot (Claude)
↓
files/search/edit tools
但当我让 Agent 帮我写个文件时,它信誓旦旦地说:
✅ 写好了!📄 文件在
first-sign.md
然而——文件根本不存在。
根因分析:Tools 参数被「吃掉了」
我开始深挖 session logs。OpenClaw 会把每次对话记录成 JSONL 文件。打开一看:
{
"type": "text",
"text": "写好了!文件在 first-sign.md"
}
没有 tool_use block。Agent 根本没调用工具——它只是「说」自己调用了。
这是典型的 LLM 幻觉(Hallucination),但为什么会发生?
答案藏在 CopilotX 的 translator.py 里。这个文件负责把 Anthropic 格式的请求翻译成 OpenAI 格式发给 Copilot:
def anthropic_to_openai_request(body: dict) -> dict:
# 处理 messages, system, model, temperature...
# ❌ 但完全没处理 tools 参数!
OpenClaw 发送的请求里带着完整的 tools 定义(read/write/edit 等),但 CopilotX 在翻译时把它们丢弃了。模型收到的请求里根本没有可用工具,自然无法调用。
解决方案:完整的 Tools 双向转换
要修复这个问题,需要实现完整的 Anthropic ↔ OpenAI tools 格式双向转换。这涉及三个层面:
1. 请求侧:Tool Definitions 转换
Anthropic 格式:
{
"tools": [{
"name": "calculator",
"description": "Math calc",
"input_schema": {"type": "object", "properties": {...}}
}]
}
OpenAI 格式:
{
"tools": [{
"type": "function",
"function": {
"name": "calculator",
"description": "Math calc",
"parameters": {"type": "object", "properties": {...}}
}
}]
}
2. 请求侧:历史消息中的 tool_use/tool_result
当 Agent 进行多轮工具调用时,历史消息中会包含之前的工具调用和结果。
Anthropic 把这些放在 content blocks 里:
{"role": "assistant", "content": [
{"type": "text", "text": "Let me calculate..."},
{"type": "tool_use", "name": "calculator", "input": {...}}
]}
而 OpenAI 用独立的 tool_calls 字段和 role: "tool" 消息:
{"role": "assistant", "content": "Let me calculate...", "tool_calls": [...]}
{"role": "tool", "tool_call_id": "xxx", "content": "425"}
3. 响应侧:tool_calls → tool_use
模型返回 tool 调用时,OpenAI 格式:
{
"finish_reason": "tool_calls",
"message": {
"content": "I'll calculate that for you.",
"tool_calls": [{"id": "...", "function": {"name": "calculator", "arguments": "{...}"}}]
}
}
需要转成 Anthropic 格式:
{
"stop_reason": "tool_use",
"content": [
{"type": "text", "text": "I'll calculate that for you."},
{"type": "tool_use", "id": "...", "name": "calculator", "input": {...}}
]
}
踩坑:Copilot 的 Split-Choices 格式
当我以为搞定了,测试时发现响应里只有 text,tool_use block 消失了。
调试发现,GitHub Copilot 后端返回的格式很「特别」——它把 text 和 tool_calls 放在两个独立的 choices 里:
{
"choices": [
{"message": {"content": "I'll calculate..."}}, // choice[0]: 只有 text
{"message": {"tool_calls": [...]}} // choice[1]: 只有 tool_calls
]
}
而标准 OpenAI 格式是把它们放在同一个 choice 里:
{
"choices": [
{"message": {"content": "...", "tool_calls": [...]}}
]
}
所以我的转换代码只读 choices[0],自然丢失了 tool_calls。
修复:遍历所有 choices,合并 text 和 tool_calls:
def openai_to_anthropic_response(openai_resp: dict, model: str) -> dict:
content_text = ""
all_tool_calls = []
# 合并所有 choices 的内容
for choice in openai_resp.get("choices", []):
message = choice.get("message", {})
if message.get("content"):
content_text += message["content"]
if message.get("tool_calls"):
all_tool_calls.extend(message["tool_calls"])
# 构建 Anthropic content blocks
content_blocks = []
if content_text:
content_blocks.append({"type": "text", "text": content_text})
for tc in all_tool_calls:
content_blocks.append({
"type": "tool_use",
"id": tc["id"],
"name": tc["function"]["name"],
"input": json.loads(tc["function"]["arguments"])
})
return {
"content": content_blocks,
"stop_reason": "tool_use" if all_tool_calls else "end_turn",
...
}
最终测试
修复部署后,测试 API:
curl -s https://api.polly.wang/v1/messages \
-H "x-api-key: $KEY" \
-d '{
"model": "claude-sonnet-4",
"messages": [{"role": "user", "content": "Use calculator to compute 25*17"}],
"tools": [{"name": "calculator", ...}]
}'
响应:
{
"stop_reason": "tool_use",
"content": [
{"type": "text", "text": "I'll use the calculator tool to compute 25*17 for you."},
{"type": "tool_use", "id": "tooluse_xxx", "name": "calculator", "input": {"expr": "25*17"}}
]
}
完美!✅
回到 OpenClaw,让小龙虾写文件:
🐾: 帮我写个 hello.md 文件
📄 Command: ~/.openclaw/workspace/hello.md
✅ Successfully wrote 149 bytes
这次是真的写入了。cat ~/.openclaw/workspace/hello.md 确认文件存在。
版本总结
| 版本 | 特性 | 代码量 |
|---|---|---|
| v2.2.1 | Config 命令 | - |
| v2.3.0 | 完整 Anthropic tools 支持 | +822 行 |
| v2.3.1 | 修复 Copilot split-choices 格式 | +115 行测试 |
关键实现:
anthropic_tools_to_openai(): 工具定义转换anthropic_tool_use_to_openai(): tool_use block 转换anthropic_tool_result_to_openai(): tool_result 转换openai_tool_calls_to_anthropic(): 响应 tool_calls 转换- Split-choices 合并逻辑
经验教训
- LLM 幻觉不总是模型的错 —— 有时是你没把工具告诉它
- 不同后端的格式差异比想象中大 —— Copilot 的 split-choices 就是个例子
- 先测 API 再测应用 —— 用 curl 隔离问题比在 OpenClaw 里调试快得多
- session logs 是调试利器 —— 它告诉你 Agent 到底做了什么
下一步:让小龙虾帮我管理博客、记录日常,真正成为「数字分身」的一部分。
深夜的硬盘不睡觉,但程序员该睡了。🐾

