解决 LLM 在工具调用场景下的幻觉问题

2026-03-21 • 2.5分钟 • 1.0千字

解决 LLM 在工具调用场景下的幻觉问题

问题背景

在开发一个 AI Agent 系统时,我们遇到了一个严重的幻觉问题:当用户请求生成图片时,LLM 不调用 image_generate 工具,而是直接编造一个假的图片 URL 返回给用户。

问题现象

用户发送消息:“再生成两张图片”

期望行为:

LLM → 调用 image_generate 工具 → 获取真实图片 URL → 返回给用户

实际行为:

LLM → 直接编造假 URL → 返回给用户
"![生成的图片](https://example.com/images/generated_6a7b8c9d-0e1f-2a3b-4c5d.png)"

这个假 URL 的格式和真实 URL 非常相似,但文件实际不存在,导致用户看到的是破损的图片。

问题严重性

问题分析

日志分析

通过分析 Agent 日志,我们发现:

[AgentContext] 📜 HISTORY MESSAGES: 880 messages, 113889 chars  ← 上下文很长
[AgentContext] 💬 CURRENT USER MESSAGE: 再生成两张
[AgentContext] 📤 RESULT: ...![图片](https://example.com/images/generated_xxx.png)...
[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: "图片已生成!![图片](https://example.com/real_image.png)"

LLM 从历史消息中学到的模式: > “用户要图片?直接返回一个图片 URL 就行了!”

LLM 不知道这个 URL 是通过工具调用获取的,它以为可以直接”写”一个 URL。

为什么会产生这种幻觉?

  1. 模式匹配:LLM 在历史对话中看到过很多图片 URL,学会了这种格式
  2. 上下文稀释:在长上下文中,system prompt 中”必须调用工具”的指令被稀释
  3. 缺乏负反馈:历史消息中没有展示”工具调用→获取结果”的完整过程

尝试的方案

方案一:强化 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...
✅ 分镜生成完成
![图片](https://example.com/fake_image.png)"  ← 还是假的

方案四:注入完整的工具调用链(成功!)

关键洞察:必须让 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": "图片已生成!![图片](https://...)"}

之前的方案只是在文本中”描述”工具调用,而不是使用正确的消息格式。LLM 无法从文本描述中学会工具调用的正确方式。

最终解决方案

核心思路

  1. 持久化工具调用链:在工具执行时,将 tool_calltool_result 保存到数据库
  2. 加载时还原格式:从数据库加载历史消息时,将其转换为 OpenAI 的工具调用格式
  3. 兼容旧数据:对于没有工具调用记录的旧消息,自动注入模拟的工具调用链

实现架构

┌─────────────────────────────────────────────────────────────┐
│                      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: 图片已生成!![图片](https://example.com/real.png)

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 并自动注入模拟的工具调用链来兼容旧数据。

适用场景

这个方案适用于:

  1. Agent 系统:使用 ReAct 模式的 AI Agent
  2. 长对话场景:历史消息较多,容易出现指令遗忘
  3. 工具调用类任务:图片生成、视频生成、API 调用等需要外部工具的场景
  4. 多轮迭代场景:用户可能多次要求”再来一次”、“重新生成”

总结

LLM 幻觉问题的本质是:模型从历史数据中学到了错误的模式

解决方案不是简单地”告诉”模型不要这样做(强化 prompt),而是要改变它看到的数据,让它从正确的示例中学习。

通过持久化完整的工具调用链,并在加载时还原为正确的 API 格式,我们成功让 LLM 学会了”生成图片必须调用工具”这一正确模式,彻底解决了图片生成的幻觉问题。