hallucination-root-cause-and-fix.md 7.9 KB

Agent67 幻觉问题:根因与解决方案

日期: 2026-04-03 ~ 2026-04-04


1. 问题描述

agent67 通过 AgentPaaS 或 run.py 执行编程任务时,出现严重的幻觉

  • 声称"所有测试通过"但从未运行测试
  • 声称"已提交推送"但没有执行 git 命令
  • 编造完整的项目分析(Flask 电商后端)但实际文件是 Java 数据结构作业
  • 工具调用失败后不重试,直接编造成功结果

2. 根因分析

2.1 核心原因:claude -p 无状态模式

agent67 的 LLM 后端 ClaudeLam 使用 claude -p(pipe mode)调用 Claude:

每一步 ReAct:
  subprocess.run(["claude", "-p", "--system-prompt", prompt], input=state)
                 ↑ 新进程,无上下文记忆

每一步都是独立的子进程调用。Claude 没有前几步的记忆,必须从 state 字符串重建上下文。当 state 被压缩或截断时,关键信息丢失,导致 LLM:

  1. 不知道之前读过哪些文件
  2. 不知道工具调用失败了
  3. 重复读同一个文件(死循环)
  4. 凭空编造之前"做过"的操作

2.2 五个具体因素

# 因素 影响
1 工具输入格式不匹配 from_config 传 dict,PersonalAssistant 传 JSON string。dict 路径跳过了 _parse_input 的容错逻辑,导致 VALIDATION_ERROR
2 MCP 工具泄漏 claude -p 继承当前 Claude Code 会话的 MCP 配置(Vercel/Gmail),LLM 认为自己只有这些工具,拒绝使用 system prompt 中声明的工具
3 观察结果被截断 PersonalAssistant 的 observations 累积后过长,context_manager.compact() 压缩丢失关键内容;from_config_MAX_OBS_LENGTH=800 导致 find 输出被截断
4 stop_condition bug result[-200:] 检查截掉了短结果中的 [Step 标记,导致误判为最终回复
5 无终止验证 LLM 调用 terminate 时可以在 summary 中编造任意内容,没有验证声称的操作是否真正执行

2.3 两条执行路径对比

PersonalAssistant 路径 (run.py):
  用户 → PersonalAssistant.chat()
       → ClaudeLam("claude -p") → LLM 输出
       → parse_and_execute() 硬编码路由
       → BUILTIN_TOOLS[name].apply(json_string)

from_config 路径 (agentpaas chat):
  用户 → POST /agents/{id}/run
       → from_config("agent-config.yml") → Loop(react_step)
       → ClaudeLam("claude -p") → LLM 输出
       → _extract_tool_call() → _timeout_call(tool, dict)

PersonalAssistant 路径最初没有幻觉,是因为 json.dumps() 序列化工具输入,恰好绕过了 schema 验证的严格模式。


3. 解决方案

3.1 最终方案:ConversationLam + 统一 Provider

根本修复:引入 ConversationLam 包装层,将任意 LLMProvider 从无状态改为有状态。不再局限于 Claude Code 的 --resume,而是对所有 Provider 统一提供完整对话历史。

class ConversationLam:
    """将任意 LLMProvider 包装为有状态的对话调用"""
    def __init__(self, provider: LLMProvider):
        self.provider = provider
        self.messages = []  # 完整对话历史

    def send(self, content: str, role: str = "user") -> str:
        self.messages.append({"role": role, "content": content})
        response = self.provider.chat(self.messages)
        self.messages.append({"role": "assistant", "content": response})
        return response

不同 Provider 的底层实现各异,但对 ConversationLam 透明:

Provider 底层会话机制
ClaudeCodeProvider 首次调用获取 session_id,后续用 --resume 恢复会话
OpenAICompatProvider 每次调用传完整 messages 数组(适用于 OpenAI / DashScope / DeepSeek 等)

react_step 因此大幅简化:

def react_step(state, step_index):
    if step_index == 0:
        response = conversation.send(full_input)       # 完整输入
    else:
        response = conversation.send(latest_observation) # 只发最新 observation
    # provider 内部已持有完整历史,无需 state 压缩/滑窗

效果

无状态 (-p) 有状态 (ConversationLam)
上下文 每步从 state 字符串重建 Provider 保持完整会话记忆
文件内容 被压缩/截断后可能丢失 LLM 记得之前读过的所有文件
工具调用历史 依赖 state 中的 [Step N] 标记 LLM 记得每一步的操作和结果
死循环 常见(反复读同一文件) 极少(LLM 知道已经读过了)
幻觉 频繁(丢失上下文后编造) 零幻觉(完整记忆,已验证)
Provider 支持 仅 Claude Code Claude Code / OpenAI / DashScope / DeepSeek 等全部支持

3.2 辅助修复(仍然有价值)

即使有了会话持久化,以下修复仍然保留以提供额外防护:

MCP 隔离

claude -p --mcp-config '{"mcpServers":{}}' --strict-mcp-config

防止 Claude 看到 Vercel/Gmail 等无关 MCP 工具。所有路径都需要

工具输入序列化(P0)

# from_config 路径:dict → JSON string,与 PersonalAssistant 对齐
if isinstance(tool_input, dict):
    tool_input_val = json.dumps(tool_input, ensure_ascii=False)

工具参数别名

# _parse_input: LLM 常用 "path" 代替 "file_path"
_ALIASES = {"path": "file_path", "filepath": "file_path", ...}

自动工具参数文档(P1)

# 从 schema 自动生成,注入 system prompt
- **ReadFile**: `{"action":"ReadFile","input":{"file_path": ..., "offset": 0, "limit": 2000}}`
- **Bash**: `{"action":"Bash","input":{"command": ..., "timeout": 120}}`

观察滑窗(PersonalAssistant)

# 只保留最近 5 步完整结果,旧的压缩为摘要
if len(observations) > 5:
    # 旧步骤:一行摘要
    # 最近 5 步:完整内容

工具名提示

# --resume 后 Claude 倾向于用内置工具名 (Read/Write/Edit)
"工具名是 ReadFile(不是Read)、WriteFile(不是Write)、EditFile(不是Edit)"

4. 使用方式

推荐:统一入口(ConversationLam,所有 Provider 均无幻觉)

python3 -m agentpaas chat lambda

指定 Provider

# Claude Code 后端(使用 --resume 会话持久化)
python3 -m agentpaas chat lambda --provider claude-code

# OpenAI 兼容后端(传完整 messages 数组)
python3 -m agentpaas chat lambda --provider openai-compat

PersonalAssistant 路径(仍可用)

python3 agentexample/agent67/run.py --claude

带 PaaS 追踪

AGENTPAAS_API_KEY=<key> python3 agentexample/agent67/launch_paas.py chat

5. 关键代码变更

文件 变更
lambdagent/providers/claude_code.py ClaudeLam 会话持久化(_session_id + --resume
lambdagent/providers/__init__.py 新包,导出 ClaudeLam
agentexample/agent67/core/assistant.py inject_override=False、observations 滑窗、工具名提示、max_steps=30
agentexample/agent67/core/claude_lam.py Re-export from lambdagent.providers
agentexample/agent67/run.py --claude 参数
lambdagent/fromconfig/compiler.py P0-P3 修复、工具参数文档、terminate 验证
lambdagent/builtin_tools/file_tools.py ListFiles 默认 pattern、参数别名

6. 经验教训

  1. 无状态 LLM 调用不适合多步任务 — 每步重建上下文的信息损失是幻觉的根本原因
  2. 会话持久化是最有效的反幻觉手段 — 比 prompt 工程、输出验证、关键词检测都更可靠
  3. Prompt 层面的防幻觉规则效果有限 — LLM 会用各种变体措辞绕过关键词检测
  4. 工具 schema 文档很重要 — LLM 不知道参数名就会猜,猜错导致 VALIDATION_ERROR,进而触发幻觉
  5. MCP 工具隔离必须全局生效 — 不隔离会导致 LLM 认知混乱