解决 LLM 在工具调用场景下的幻觉问题
曦子解决 LLM 在工具调用场景下的幻觉问题
问题背景
在开发一个 AI Agent 系统时,我们遇到了一个严重的幻觉问题:当用户请求生成图片时,LLM 不调用 image_generate 工具,而是直接编造一个假的图片 URL 返回给用户。
问题现象
用户发送消息:“再生成两张图片”
期望行为:
LLM → 调用 image_generate 工具 → 获取真实图片 URL → 返回给用户
实际行为:
LLM → 直接编造假 URL → 返回给用户
""
这个假 URL 的格式和真实 URL 非常相似,但文件实际不存在,导致用户看到的是破损的图片。
问题严重性
- 用户体验极差:图片无法显示
- 难以发现:假 URL 格式正确,不会触发程序错误
- 换模型无法解决:多个模型都有类似问题
问题分析
日志分析
通过分析 Agent 日志,我们发现:
[AgentContext] 📜 HISTORY MESSAGES: 880 messages, 113889 chars ← 上下文很长
[AgentContext] 💬 CURRENT USER MESSAGE: 再生成两张
[AgentContext] 📤 RESULT: ......
[Agent] Final turn - has_tool_calls=False, tool_calls=[], stop_reason=stop
关键发现: 1. 上下文过长:880 条历史消息,超过 11 万字符 2. 工具未调用:has_tool_calls=False,LLM 没有发起任何工具调用 3. 直接返回文本:LLM 直接在文本中编造了图片 URL
根因分析
深入分析后,我们发现问题的根本原因是:历史消息中没有保存工具调用的过程。
当前的消息存储逻辑:
user: "生成一张图片"
assistant: "图片已生成!"
LLM 从历史消息中学到的模式: > “用户要图片?直接返回一个图片 URL 就行了!”
LLM 不知道这个 URL 是通过工具调用获取的,它以为可以直接”写”一个 URL。
为什么会产生这种幻觉?
- 模式匹配:LLM 在历史对话中看到过很多图片 URL,学会了这种格式
- 上下文稀释:在长上下文中,system prompt 中”必须调用工具”的指令被稀释
- 缺乏负反馈:历史消息中没有展示”工具调用→获取结果”的完整过程
尝试的方案
方案一:强化 System Prompt(失败)
在 system prompt 中添加更强的约束:
6. **善用 tools**: 严禁使用假的图片链接, 无论失败多少次, 生成图片都一定要使用 tools!
7. **真实性**: 不允许编造图片链接, 没有调用 tools 不可以输出图片链接.
结果:无效。在长上下文中,这些指令的影响力不足。
方案二:替换历史消息中的图片 URL(部分有效)
将历史消息中的图片 URL 替换为占位符:
def _convert_messages(self, messages):
for msg in messages:
if msg.role == "assistant":
# 把 URL 替换成占位符
content = re.sub(r'https://.*\.png', '[图片#1]', msg.content)结果:LLM 不再编造类似格式的 URL,但仍然不调用工具。
方案三:注入文本提示(失败)
在包含图片的历史消息前插入文本提示:
# 在图片消息前插入
api_messages.append({
"role": "assistant",
"content": "生成图片必须要调用工具, calling tools: image_generate..."
})结果:LLM 学会了”说”要调用工具,但实际没有调用:
LLM 输出: "根据系统提示词,生成图片必须要调用工具, calling tools: image_generate...
✅ 分镜生成完成
" ← 还是假的
方案四:注入完整的工具调用链(成功!)
关键洞察:必须让 LLM 看到完整的 OpenAI 工具调用格式。
OpenAI API 的工具调用是三段式结构:
// 1. assistant 发起工具调用
{"role": "assistant", "content": null, "tool_calls": [{"id": "call_xxx", "function": {"name": "image_generate", "arguments": "..."}}]}
// 2. tool 返回结果
{"role": "tool", "tool_call_id": "call_xxx", "content": "{\"images\": [\"https://...\"]}"}
// 3. assistant 基于结果回复
{"role": "assistant", "content": "图片已生成!"}之前的方案只是在文本中”描述”工具调用,而不是使用正确的消息格式。LLM 无法从文本描述中学会工具调用的正确方式。
最终解决方案
核心思路
- 持久化工具调用链:在工具执行时,将
tool_call和tool_result保存到数据库 - 加载时还原格式:从数据库加载历史消息时,将其转换为 OpenAI 的工具调用格式
- 兼容旧数据:对于没有工具调用记录的旧消息,自动注入模拟的工具调用链
实现架构
┌─────────────────────────────────────────────────────────────┐
│ Agent 执行流程 │
├─────────────────────────────────────────────────────────────┤
│ on_tool_call(image_generate) │
│ ↓ │
│ 生成 tool_call_id │
│ ↓ │
│ 保存 message_type="tool_call" ──→ DB │
│ ↓ │
│ 执行工具 │
│ ↓ │
│ on_tool_result() │
│ ↓ │
│ 保存 message_type="tool_result" ──→ DB │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 下次请求加载历史 │
├─────────────────────────────────────────────────────────────┤
│ 从 DB 加载消息 │
│ ↓ │
│ 识别 tool_call / tool_result 消息 │
│ ↓ │
│ 转换为 OpenAI 工具调用格式 │
│ ↓ │
│ LLM 看到完整的工具调用过程 │
│ ↓ │
│ LLM 学会:生成图片 = 调用工具 │
└─────────────────────────────────────────────────────────────┘
代码实现
1. 保存工具调用消息
def on_tool_call(name: str, args: dict) -> None:
if name == "image_generate":
# 生成唯一的 tool_call_id(包含时间戳防止重复)
call_id = f"call_img_{conversation_id}_{int(time.time()*1000)}_{counter}"
# 保存到数据库
tool_call_data = json.dumps({
"tool_call_id": call_id,
"name": name,
"arguments": args
})
await conversation_service.add_message(
conversation_id, "assistant", tool_call_data, db, "tool_call"
)
def on_tool_result(name: str, result_str: str) -> None:
if name == "image_generate" and current_tool_call_id:
# 保存工具结果
tool_result_data = json.dumps({
"tool_call_id": current_tool_call_id,
"name": name,
"content": result_str
})
await conversation_service.add_message(
conversation_id, "tool", tool_result_data, db, "tool_result"
)2. 加载历史消息时注入特殊标记
for msg in history_messages:
msg_type = msg.message_type
if msg_type == "tool_call":
# 添加特殊前缀,便于后续识别
runner.add_message("assistant", f"__TOOL_CALL__:{msg.content}")
elif msg_type == "tool_result":
runner.add_message("tool", f"__TOOL_RESULT__:{msg.content}")
else:
runner.add_message(msg.role, msg.content)3. 转换为 OpenAI 格式
def _convert_messages(self, messages, system_prompt=None):
api_messages = []
for msg in messages:
content = msg.content
# 处理持久化的 tool_call 消息
if content.startswith("__TOOL_CALL__:"):
data = json.loads(content[len("__TOOL_CALL__:"):])
api_messages.append({
"role": "assistant",
"content": None,
"tool_calls": [{
"id": data["tool_call_id"],
"type": "function",
"function": {
"name": data["name"],
"arguments": json.dumps(data.get("arguments", {}))
}
}]
})
continue
# 处理持久化的 tool_result 消息
if content.startswith("__TOOL_RESULT__:"):
data = json.loads(content[len("__TOOL_RESULT__:"):])
api_messages.append({
"role": "tool",
"tool_call_id": data["tool_call_id"],
"content": data.get("content", "")
})
continue
# 兼容旧数据:为没有工具调用链的图片消息注入模拟调用
if msg.role == "assistant" and has_image_url(content):
image_urls = extract_image_urls(content)
call_id = f"call_legacy_{counter}"
# 注入模拟的工具调用
api_messages.append({
"role": "assistant",
"content": None,
"tool_calls": [{
"id": call_id,
"type": "function",
"function": {
"name": "image_generate",
"arguments": json.dumps({"prompt": "用户请求的图片"})
}
}]
})
api_messages.append({
"role": "tool",
"tool_call_id": call_id,
"content": json.dumps({"success": True, "images": image_urls})
})
# 普通消息
api_messages.append({"role": msg.role, "content": content})
return api_messages效果验证
修改后,LLM 看到的历史消息格式:
[5] assistant: tool_calls=['image_generate'] args=['{"prompt": "战斗机", "purpose": "storyboard"}']
[6] tool[call_img_771_1711234567890_1]: {"success": true, "images": ["https://example.com/real.png"]}
[7] assistant: 图片已生成!
LLM 通过 in-context learning 学到了正确的模式: > “要生成图片,必须先发起 tool_call,然后从 tool_result 中获取真实的 URL”
关键经验总结
1. 工具调用是一种”结构”,不是”文本”
LLM 不会从文本描述中学会工具调用。你必须使用 API 定义的正确消息格式(tool_calls 字段、role: "tool" 等)。
2. In-Context Learning 很强大
LLM 会从历史消息中学习模式。如果历史中都是”直接返回 URL”,它就学会直接返回;如果历史中都是”先调用工具再返回”,它就学会调用工具。
3. 持久化工具调用链很重要
对于需要工具调用的 Agent 系统,应该持久化完整的工具调用过程,而不仅仅是最终结果。这对于: - 防止幻觉 - 调试排查 - 审计追踪
都非常有价值。
4. 旧数据兼容不可忽视
在修改消息存储格式时,必须考虑旧数据的兼容。本案例中,我们通过检测图片 URL 并自动注入模拟的工具调用链来兼容旧数据。
适用场景
这个方案适用于:
- Agent 系统:使用 ReAct 模式的 AI Agent
- 长对话场景:历史消息较多,容易出现指令遗忘
- 工具调用类任务:图片生成、视频生成、API 调用等需要外部工具的场景
- 多轮迭代场景:用户可能多次要求”再来一次”、“重新生成”
总结
LLM 幻觉问题的本质是:模型从历史数据中学到了错误的模式。
解决方案不是简单地”告诉”模型不要这样做(强化 prompt),而是要改变它看到的数据,让它从正确的示例中学习。
通过持久化完整的工具调用链,并在加载时还原为正确的 API 格式,我们成功让 LLM 学会了”生成图片必须调用工具”这一正确模式,彻底解决了图片生成的幻觉问题。