分析时间: 2026-04-03 对比基准: commit
4d3cb1ba4d(硬编码版本) vs 当前 HEAD (from_config + ClaudeLam)
硬编码路径 (PersonalAssistant):
run.py / launch_paas.py chat
→ PersonalAssistant.chat()
→ ClaudeLam("claude -p") → LLM 输出
→ parse_and_execute() 硬编码路由
→ BUILTIN_TOOLS[name].apply(json_string)
from_config 路径 (PaaS):
agentpaas chat → POST /agents/{id}/run
→ _execute_agent() → from_config("agent-config.yml")
→ _compile_react() → Loop(react_step)
→ ClaudeLam("claude -p") → LLM 输出
→ _extract_tool_call() → _timeout_call(tool, input)
| 组件 | PersonalAssistant | from_config |
|---|---|---|
| LLM 后端 | ClaudeLam (agent67/core/) |
ClaudeLam (lambdagent/providers/) |
| System Prompt | core/prompt.py 硬编码 |
agent-config.yml YAML 声明 |
| 工具注册 | BUILTIN_TOOLS + 4 macOS 工具 |
YAML mcp.localTools 声明 |
| ReAct 循环 | chat() 方法内 for 循环 |
Loop(react_step, stop_condition) |
| 工具输入传递 | json.dumps(dict) → JSON 字符串 |
直接传 dict (修复前) |
| 观察反馈 | [工具: X]\n[执行结果] Y |
[Step N]\nThought:...\nAction:...\nObservation:... |
| 配置方式 | 改 Python 代码 | 改 YAML 文件 |
| API Key | 不需要 (Claude Code CLI) | 不需要 (provider: claude-code) |
用户: "分析 ~/Desktop/xxx 下的代码"
↓
Step 1: LLM 输出 {"action":"ListFiles","input":{"path":"~/..."}}
↓
_extract_tool_call() 提取 tool_input = {"path": "~/..."} (dict)
↓
_timeout_call(ListFiles, {"path": "~/..."}) (直接传 dict)
↓
ValidatedTool → ListFilesSchema(**{"path": "~/..."})
↓
TypeError: missing 1 required positional argument: 'pattern'
→ VALIDATION_ERROR
↓
LLM 收到错误 → 不重试 → 编造整个分析报告
→ "这是一个 Flask 电商后端服务..." (100% 虚构)
| # | 因素 | 位置 | 硬编码路径 | from_config 路径 |
|---|---|---|---|---|
| 1 | 工具输入格式 | tool 调用处 | json.dumps(dict) → 字符串 |
直接传 dict |
| 2 | MCP 工具泄漏 | claude -p 参数 |
无 --strict-mcp-config (但 prompt 补偿) |
无 --strict-mcp-config (LLM 看到 Vercel/Gmail) |
| 3 | 工具 schema 不匹配 | ListFilesSchema |
同样存在,但被字符串解析路径容错 | dict 直传导致 TypeError |
| 4 | keyword match 误触发 | _extract_tool_call |
无此逻辑 (用 parse_and_execute) |
有 keyword fallback,文本中提到工具名即触发 |
| 5 | stop_condition 截断 | compiler.py |
无此逻辑 (用 is_done 标志) |
result[-200:] 截掉了 [Step 标记 |
核心差异在工具输入传递方式:
# PersonalAssistant (无幻觉):
tool_input = data.get("input", {}) # {"path": "~/..."}
tool_input_str = json.dumps(tool_input) # '{"path": "~/..."}'
result = tool.apply(tool_input_str) # 传 JSON 字符串
# from_config (有幻觉, 修复前):
tool_input = data.get("input") # {"path": "~/..."}
_timeout_call(tool, tool_input) # 直接传 dict
JSON 字符串传入 _parse_input() 后走 isinstance(str) 分支:
json.loads('{"path": "~/..."}') → dictpattern → fallback {"file_path": "~/..."}ListFilesSchema(file_path="~/...") → 虽然参数名错了,但不会 crashdict 直传走 isinstance(dict) 分支:
ListFilesSchema(**{"path": "~/..."}) → 直接传给构造器pattern → TypeError → VALIDATION_ERROR| 优先级 | 修复 | 对标硬编码路径 | 文件 |
|---|---|---|---|
| P0 | 工具输入序列化为 JSON 字符串 | 对齐 json.dumps() |
compiler.py:594 |
| P1 | 自动生成工具参数文档注入 prompt | 硬编码 prompt 经过 6 轮迭代 | compiler.py:_generate_tool_schema_docs() |
| P2 | pending action 检测 | 对标 has_pending_action 逻辑 |
compiler.py:620-640 |
| P3 | state 注入步数信息 | 硬编码版有 [步骤 N/15] |
compiler.py:680 |
| 修复 | 问题 | 文件 |
|---|---|---|
--strict-mcp-config |
MCP 工具泄漏 (Vercel/Gmail) | providers/claude_code.py |
_TOOL_OVERRIDE + [CRITICAL RULES] |
--tools "" 让 LLM 认为没工具 |
providers/claude_code.py |
| 移除 keyword match | 文本中提到工具名误触发 | compiler.py:_extract_tool_call() |
stop_condition 全文检查 |
[-200:] 截掉 [Step 标记 |
compiler.py:stop_condition() |
_compress_state 错误标记 |
工具失败后 LLM 不重试 | compiler.py:_compress_state() |
ListFilesSchema pattern 默认值 |
缺 pattern 直接报错 | file_tools.py:265 |
_parse_input 参数别名 |
path→file_path 等映射 |
file_tools.py:_parse_input() |
| terminate 验证 | LLM 声称完成但实际未执行 | compiler.py:615-645 |
| 观察截断增大 | 800→3000,避免 find 输出被截导致重试 | compiler.py:_MAX_OBS_LENGTH |
| 超时增大 | 180s→300s,复杂思考被中断 | providers/claude_code.py |
LLM 输出: {"action":"ReadFile","input":{"path":"~/test.py"}}
↓
parse_and_execute() 提取:
tool_input = {"path": "~/test.py"}
tool_input_str = '{"path": "~/test.py"}'
↓
tool.apply('{"path": "~/test.py"}')
↓
ValidatedTool._validate_and_call(input='{"path": "~/test.py"}')
↓
_parse_input('{"path": "~/test.py"}', ReadFileSchema)
isinstance(str) → json.loads() → {"path": "~/test.py"}
↓
检查 nested "input" → 无 → 继续
↓
参数别名: "path" → "file_path" (file-related schema)
↓
ReadFileSchema(file_path="~/test.py") → OK
LLM 输出: {"action":"ReadFile","input":{"path":"~/test.py"}}
↓
_extract_tool_call() 提取:
tool_input = {"path": "~/test.py"} (dict)
↓
_timeout_call(tool, {"path": "~/test.py"})
↓
ValidatedTool._validate_and_call(input={"path": "~/test.py"})
↓
_parse_input({"path": "~/test.py"}, ReadFileSchema)
isinstance(dict) → 直接传
↓
ReadFileSchema(**{"path": "~/test.py"})
→ TypeError: unexpected keyword argument 'path'
→ VALIDATION_ERROR
LLM 输出: {"action":"ReadFile","input":{"path":"~/test.py"}}
↓
_extract_tool_call() 提取:
tool_input = {"path": "~/test.py"} (dict)
↓
json.dumps(tool_input) → '{"path": "~/test.py"}' ← P0 修复
↓
_timeout_call(tool, '{"path": "~/test.py"}')
↓
(与 PersonalAssistant 路径相同)
→ ReadFileSchema(file_path="~/test.py") → OK
经过 6 轮 commit 迭代打磨 (6239ca6 → 645c3c4):
通过自动化机制弥补:
_TOOL_OVERRIDE: 运行时注入 [RUNTIME ENVIRONMENT] 和 [CRITICAL RULES]_generate_tool_schema_docs(): 从 schema 自动生成参数签名文档--strict-mcp-config: CLI 参数隔离 MCP 工具for step in range(max_steps):
llm_output = self.brain.apply(input)
result, is_done, tool_name = parse_and_execute(llm_output)
if is_done:
break # terminate 信号
if result != llm_output:
# 工具被执行 → 加入 observations
observations.append(f"[工具: {tool_name}]\n[执行结果] {result}")
else:
# 纯文本回复 → 检查是否有未完成操作
if has_pending_action and not is_truly_done:
observations.append("[系统提醒] 请立即调用工具完成操作")
else:
break # 最终回复
特点:
has_pending_action 检测未完成操作def react_step(state):
thought = think.apply(state) # LLM 思考
# Phase 1.5: 隐式终止检测
if _check_implicit_terminate(thought):
...
# Phase 2: 工具提取
selected_tool, tool_input = _extract_tool_call(thought, tools)
# Phase 3: 终止/pending action 检测
if selected_tool is None or selected_tool._name == "terminate":
# P2: 验证声称 vs 实际执行
if is_fabricating:
return _compress_state(..., "[SYSTEM] 操作未实际执行")
...
# Phase 4: 工具执行 (P0: 序列化为 JSON 字符串)
tool_input_val = json.dumps(tool_input) if isinstance(tool_input, dict) else ...
observation = _timeout_call(tool, tool_input_val, timeout)
# Phase 5: 状态压缩 + P3 步数注入
compressed = _compress_state(state, thought, tool_name, observation)
compressed += f"\n\n[剩余 {remaining} 步可用]"
return compressed
特点:
_compress_state 滑窗压缩,防止 state 无限增长| 层 | 机制 | 防御目标 |
|---|---|---|
| CLI 层 | --strict-mcp-config + --tools "" |
MCP 工具泄漏 / Claude 自执行 |
| Prompt 层 | _TOOL_OVERRIDE + [CRITICAL RULES] |
LLM 拒绝使用工具 |
| Schema 层 | _generate_tool_schema_docs() |
LLM 猜错参数名 |
| 输入层 | P0 JSON 序列化 + 参数别名 | 工具 VALIDATION_ERROR |
| 观察层 | _compress_state 错误标记 |
工具失败后编造结果 |
| 循环层 | pending action + terminate 验证 | 提前声称完成 / 跳步 |
| 步数层 | [剩余 N 步] 注入 |
LLM 急于总结 |
| 截断层 | _MAX_OBS_LENGTH=3000 |
输出被截导致重复查询 |
_TOOL_OVERRIDE 占用大量 tokenclaude -p 延迟 — 每步调用 claude 子进程,冷启动 + 长 prompt = 7-40s/step硬编码路径 (PersonalAssistant) 没有幻觉不是因为 prompt 更好,而是因为 工具输入传递方式 (json.dumps) 恰好绕过了 schema 验证的严格模式。from_config 路径通过 P0 修复对齐后,加上 P1-P3 的额外防御,已经能在大多数场景下避免幻觉。
最终方案: from_config 路径在保持声明式 YAML 配置优势的同时,借鉴硬编码路径的 5 个关键模式:
上述 P0-P3 修复仅是治标——根本问题在于 claude -p 每步创建新进程,LLM 无法保持对话记忆。最终解决方案引入 ConversationLam,将任何 LLMProvider 包装为带完整对话历史的有状态调用。
ConversationLam(provider: LLMProvider)
├── messages: List[Message] # 完整对话历史
├── provider.chat(messages) → str # 每次传完整 messages 数组
└── react_step 简化:
step 0 → messages = [system, user(full_input)]
step N → messages.append(assistant(thought))
messages.append(user(observation_only))
ConversationLam 不关心底层是哪个 Provider,它只负责维护 messages 列表并在每次调用时传给 provider。
| Provider | 会话持久化方式 |
|---|---|
| ClaudeCodeProvider | 首次调用获取 session_id,后续用 --resume 恢复会话。Claude Code CLI 内部维护完整上下文 |
| OpenAICompatProvider | 每次调用传完整 messages 数组(含所有历史 user/assistant 轮次)。适用于 OpenAI、DashScope、DeepSeek 等兼容 API |
两种方式效果等价——LLM 始终拥有从第一步到当前步的完整记忆。
ConversationLam 使 react_step 逻辑大幅简化:
def react_step(state, step_index):
if step_index == 0:
# 首次调用:完整输入(system prompt + 用户任务 + 工具文档)
response = conversation.send(full_input)
else:
# 后续调用:只发送最新一步的 observation
response = conversation.send(latest_observation)
# provider 内部已持有完整历史,无需 state 压缩/滑窗
thought, action, action_input = parse_react(response)
observation = execute_tool(action, action_input)
return observation
关键变化:
_compress_state:历史在 provider 内部维护,不通过 state 字符串传递之前幻觉的根因链:
无状态调用 → state 压缩/截断 → 关键信息丢失 → LLM 不知道之前做过什么 → 编造结果
ConversationLam 切断了这条链的第一环:
有状态调用 → 完整对话历史 → LLM 记得每一步操作和结果 → 无需编造
具体表现:
在多步编程任务(WriteFile → mvn test → git push)上验证:
| 场景 | 旧方案 (claude -p + state 压缩) | 新方案 (ConversationLam) |
|---|---|---|
| 3 步任务 (读→改→写) | 偶发幻觉 | 零幻觉 |
| 5 步任务 (读→改→测→提交→推) | 高概率幻觉 | 零幻觉 |
| 10+ 步复杂任务 | 几乎必然幻觉 | 零幻觉 |
| 跨 Provider (Claude/OpenAI/DashScope) | 仅 Claude Code 可用 | 全部验证通过 |
结论:ConversationLam + 统一 Provider 是最终方案,彻底解决了多步任务中的幻觉问题,且不依赖特定 LLM 后端。