AI Agent 开发的十大陷阱:从 prompt 幻觉到工具调用死循环的防范

AI Agent 开发的十大陷阱:从 prompt 幻觉到工具调用死循环的防范 AI Agent 开发的十大陷阱从 prompt 幻觉到工具调用死循环的防范一、当我看到 Agent 把 200 元预算花光的时候今年二月我给 dayuan 加了一个代码审查 Agent功能。它的逻辑很简单收到 PR → 读取 diff → 调用 AI 审查 → 输出建议列表。上线第二天一个用户报告说 Agent疯了。它读了一个 50 行改动的 PR然后连续调用了 47 次read_file工具读了仓库里 47 个文件——包括node_modules里的。月 API 预算半小时烧光。查日志看它的思路它发现 import 语句引用了一个工具函数于是去读那个工具函数的文件 → 发现那个文件又 import 了别的 → 递归 → 停不下来。这是一个典型的无限探索陷阱Agent 把理解代码当成了目标本身忘了它真正的任务是审查 diff。AI Agent 开发听起来很酷但实际上是给模型装了一条思考-行动循环链而你永远不知道这条链会通向哪里。二、十大陷阱全景三、认知层与工具层陷阱陷阱 1Prompt 的无声偏移 —— System Prompt 在对话中慢慢变质/// ❌ 最简单的 Agent 实现 —— 也是最危险的 struct NaiveAgent { system_prompt: String, // 你是一个代码审查专家... messages: VecChatMessage, // 用户消息 工具调用结果不断追加 } impl NaiveAgent { async fn step(mut self, user_input: str) - ResultAction { // 每轮都把 system_prompt conversation_history 发给模型 self.messages.push(ChatMessage::user(user_input)); let action self.call_llm(self.messages).await?; self.messages.push(action.to_chat_message()); // 工具的返回也追加进去 Ok(action) } // 问题100 轮对话后messages 数组长达数万字 // 模型开始忽略system_prompt被最近的上下文带偏 // 这是 prompt 偏移的根源 } /// ✅ 修复方案滑动窗口 摘要压缩 struct RobustAgent { system_prompt: String, recent_messages: VecChatMessage, // 最近 20 条完整的 conversation_summary: OptionString, // 历史对话的压缩摘要 } impl RobustAgent { async fn step(mut self, user_input: str) - ResultAction { // 如果最近消息超过阈值压缩旧消息 if self.recent_messages.len() 20 { let old_messages: Vec_ self.recent_messages.drain(..15).collect(); let summary self.summarize(old_messages).await?; self.conversation_summary Some(summary); } // 构建发给 LLM 的完整上下文 let mut full_messages vec![ChatMessage::system(self.system_prompt)]; if let Some(summary) self.conversation_summary { full_messages.push(ChatMessage::system( format!([历史对话摘要]\n{}, summary) )); } full_messages.extend(self.recent_messages.clone()); full_messages.push(ChatMessage::user(user_input)); let action self.call_llm(full_messages).await?; self.recent_messages.push(action.to_chat_message()); Ok(action) } }陷阱 2Agent 忘了自己的目标 —— 沉浸在探索中这就是开头的例子。Agent 被赋予了读文件的能力后把深入理解代码当成了目标而不是手段。修复方案在每个工具调用的 prompt 中强调目标导向。你是一个代码审查 Agent。你的**唯一任务**是审查给定的 diff。 每当你考虑调用 read_file 工具时先问自己 1. 这个文件是否在本次 diff 的变更范围内 2. 不读这个文件我能否给出有意义的审查意见 3. 我已经读了多少文件是否超出了合理范围上限 5 个 如果你的目标是理解整个代码库停下来 —— 你的目标是审查这个 diff。/// ✅ 代码层面的防护工具调用计数器 struct GuardedAgent { tool_call_count: HashMapString, u32, max_tool_calls: u32, cost_so_far: f64, max_cost: f64, } impl GuardedAgent { fn can_call_tool(mut self, tool: str, estimated_cost: f64) - bool { // 检查单个工具的调用次数 let count self.tool_call_count.get(tool).unwrap_or(0); if *count self.max_calls_per_tool(tool) { tracing::warn!(tool, count, 工具 {} 调用次数超限, tool); return false; } // 检查预算 if self.cost_so_far estimated_cost self.max_cost { tracing::warn!( current self.cost_so_far, max self.max_cost, API 预算超限 ); return false; } // 记录 *self.tool_call_count.entry(tool.to_string()).or_insert(0) 1; self.cost_so_far estimated_cost; true } }陷阱 3幻觉输入产生幻觉输出/// ❌ 不加验证就把 Agent 的输出喂给下一个步骤 async fn dangerous_pipeline() { let file_list agent.ask(列出需要修改的文件).await?; // Agent 返回: [src/main.rs, src/不存在的文件.rs, node_modules/evil.rs] for file in file_list { let content read_file(file).await?; // 不存在的文件.rs 读取失败 let fix agent.ask(format!(修复 {}, file)).await?; // Agent 又编出一堆不存在的行号来修复 } } /// ✅ 每步输出都要验证 async fn safe_pipeline() { let file_list agent.ask(列出需要修改的文件).await?; // 验证 1文件是否存在 let real_files: Vec_ file_list.into_iter() .filter(|f| std::path::Path::new(f).exists()) .filter(|f| !f.contains(node_modules)) // 黑名单 .collect(); if real_files.is_empty() { return Err(anyhow::anyhow!(Agent 推荐的文件都不存在可能是幻觉)); } for file in real_files { let content read_file(file).await.context(读取文件)?; let fix agent.ask(format!(对文件 {} 提出修改建议, file)).await?; // 验证 2修改建议是否引用了文件中实际存在的行号 validate_line_numbers(fix, content)?; } }工具层的三个陷阱陷阱 4工具设计的哥德尔陷阱——工具描述越详细Agent 越容易滥用工具描述是给 LLM 看的自然语言不是给程序员看的 API 文档。写得越详细模型越有可能在不需要的时候也调用它。/// ❌ 糟糕的工具描述过于详细诱导模型滥用 pub struct ReadFileTool; impl Tool for ReadFileTool { fn description(self) - str { 读取指定文件的完整内容。适用于查看源代码、 配置文件、日志文件等。如果你想理解代码逻辑、 分析 bug 原因、或者查看错误日志请使用此工具。 // ↑ 如果你想理解代码逻辑 —— 这句话让 Agent 认为 // 读代码是目标的一部分助长了无限探索行为 } } /// ✅ 好的工具描述说清楚何时用、何时不用 impl Tool for ReadFileTool { fn description(self) - str { 读取指定路径的文件内容。仅当需要获取文件中的具体代码行、 精确的函数签名或变量定义时使用。不要用来浏览目录或 探索代码结构——用 list_files 工具代替。单次 Agent 调用 中最多使用 5 次此工具。 // ↑ 明确了边界什么场景用、什么场景不用、次数上限 } }陷阱 5无边的工具权限 —— Agent 能删库你想过吗/// ❌ 把 rm -rf 包装成 Agent 工具 struct DeleteFileTool; impl Tool for DeleteFileTool { fn description(self) - str { 删除指定文件 // ← 太危险了 } } /// ✅ 工具必须有权限边界 struct DeleteFileTool { allowed_paths: VecPathBuf, // 只允许删除这些目录下的文件 require_confirmation: bool, // 每个删除操作都要确认 max_file_size: u64, // 不删除超过 1MB 的文件 } impl Tool for DeleteFileTool { fn description(self) - str { 删除临时生成的文件。只能删除 temp/ 和 cache/ 下的文件 被系统文件保护。需要用户确认。 } async fn execute(self, path: str) - ResultString { let path PathBuf::from(path); // 安全检查 1路径必须在白名单内 if !self.allowed_paths.iter().any(|allowed| path.starts_with(allowed)) { return Err(format!(安全限制不允许删除 {} 下的文件, path.display())); } // 安全检查 2不删除大于阈值的文件 if path.exists() { let meta std::fs::metadata(path)?; if meta.len() self.max_file_size { return Err(安全限制文件过大不允许自动删除.to_string()); } } // 安全检查 3必须用户确认 if self.require_confirmation { return Ok(format!(请确认删除 {}? (回复 yes 继续), path.display())); } std::fs::remove_file(path)?; Ok(文件已删除.to_string()) } }陷阱 6忽略工具的副作用 —— 你以为是只读的工具可能不是每个工具都应该声明自己的副作用。Agent 需要知道调用这个工具会改变系统状态吗这个改变可逆吗/// ✅ 工具定义中显式声明副作用 enum SideEffect { /// 纯读取不改变任何状态 ReadOnly, /// 创建新资源文件、数据库记录 Create, /// 修改已有资源 Modify, /// 删除资源不可逆 Destructive, } struct ToolDefinition { name: String, side_effect: SideEffect, is_idempotent: bool, // 重复调用结果是否一致 estimated_cost_ms: u64, }四、控制层陷阱与可观测性陷阱 7无限循环 —— 最经典的 Agent 故障/// ✅ 多层防护机制 struct LoopPrevention { max_iterations: usize, // 最大迭代次数硬限制 max_cost: f64, // 最大 API 费用 no_progress_threshold: usize, // 连续无进展次数 consecutive_no_progress: usize, iteration_count: usize, total_cost: f64, } impl LoopPrevention { fn check_and_increment(mut self) - Result(), LoopError { self.iteration_count 1; // 硬限制 1迭代次数 if self.iteration_count self.max_iterations { return Err(LoopError::MaxIterations(self.max_iterations)); } // 硬限制 2API 费用 if self.total_cost self.max_cost { return Err(LoopError::BudgetExceeded(self.max_cost)); } Ok(()) } fn report_progress(mut self, progress_made: bool) - Result(), LoopError { if !progress_made { self.consecutive_no_progress 1; // 连续 N 次没有进展 → 终止 if self.consecutive_no_progress self.no_progress_threshold { return Err(LoopError::NoProgress( self.consecutive_no_progress )); } } else { self.consecutive_no_progress 0; } Ok(()) } }陷阱 8错误的停止条件最常见的停止条件是模型说它完成了。但模型可能被卡在某一步反复尝试每次都输出我继续尝试……。正确的停止条件 模型声明完成 结果可验证。async fn run_agent(task: str) - ResultOutput { let mut prevention LoopPrevention::new(30, 0.5); loop { prevention.check_and_increment()?; let action agent.decide_next_action().await?; match action { Action::ToolCall { tool, params } { let result execute_tool(tool, params).await?; // 关键判断这次工具调用有没有实际进展 let has_progress tool.is_progressive_result(result); prevention.report_progress(has_progress)?; agent.record_tool_result(result); } Action::FinalAnswer { answer } { // 必须验证最终结果的合理性 if validate_output(answer, task)? { return Ok(answer); } // 结果验证失败但不要无限重试 } } } }陷阱 9并发状态混乱 —— ReAct 循环里的竞态/// ❌ Agent 里使用全局可变状态 没有锁 static mut TASK_STATUS: OptionString None; // 两个并发的 Agent 实例会互相覆盖状态 /// ✅ 每个 Agent 实例拥有自己的状态通过 channel 通信 struct IsolatedAgent { state: AgentState, tools: HashMapString, Arcdyn Tool, // 工具可以共享但必须线程安全 } // 如果 Agent A 和 Agent B 需要协作用明确的 channel let (tx_ab, rx_ab) tokio::sync::mpsc::channel(10);陷阱 10不可观测的决策过程Agent 最大的黑盒不是模型参数而是它为什么做了这个决定。/// ✅ 为 Agent 的每一步决策建立审计日志 #[derive(Serialize)] struct AgentAuditEntry { timestamp: chrono::DateTimechrono::Utc, iteration: usize, action: ActionType, reasoning: String, // 模型输出的推理过程 tool_used: OptionString, tool_params: Optionserde_json::Value, tool_result: OptionString, cost_usd: f64, progress_marker: String, // exploring, solving, stuck, done } impl AgentAuditEntry { fn is_stuck_loop(self, previous: [Self]) - bool { // 检查最近 5 步是否有重复的 action 但无进展 if previous.len() 5 { return false; } let recent: Vec_ previous.iter().rev().take(5).collect(); let all_same_tool recent.windows(2) .all(|w| w[0].tool_used w[1].tool_used); let all_stuck recent.iter() .all(|e| e.progress_marker stuck); all_same_tool all_stuck } }可观测性 checklist每步决策都有日志含 reasoning tool_call result有自动检测卡住的算法API 费用实时累加超阈值自动报警提供--verbose模式让用户看到 Agent 的思考过程实操案例给代码审查 Agent 加三层防护开头的故事没有讲完——API 预算烧光后我给代码审查 Agent 加入了三层防护机制每层对应文中一个陷阱。**第一层预算围栏对应陷阱 7。**在 Agent 启动时设定三个硬限制单次任务最多 50 次工具调用、API 总费用上限 0.5 美元、连续 5 步无进展自动终止。用了一个简单的计数器结构体每步执行完自增检查。上线第一周就拦住了 3 次无限探索——Agent 追踪引用链时跑到第 21 个文件被截断输出了由于探索范围达到上限以下分析基于前 20 个文件。第二层工具调用白名单 权限分级对应陷阱 4、5。read_file不允许读取.env、id_rsa、credentials等敏感文件write_file只能在src/和tests/下创建新文件execute_command只允许cargo check、cargo test、git diff --stat三个命令。每个工具的定义里都声明了副作用等级Destructive级别的操作必须用户二次确认。**第三层审计日志 自动回放对应陷阱 10。**每一轮 Agent 的决策过程推理文本 工具调用 返回值都序列化到 JSON 文件。出了问题时我能用日志回放重现整个 Agent 的思考过程——就像看一段心路历程录像。有一次 Agent 连续三次建议修改同一个 import 语句回放发现它每轮读到的文件内容是旧的缓存版本根本没看到上一轮的修改。修复是在文件读取结果上加了最后修改时间戳的校验。这三层防护只多了 200 行代码但把 Agent 的行为从黑盒赌博变成了可控流程。现在 dayuan 的代码审查 Agent 已经稳定运行了四个月,被准确拦截了 17 次可能失控的操作而没有一次误拦。五、总结做 Agent 开发这一年多我从哇它能自己思考了的兴奋逐渐变成了每次上线都像拆弹的谨慎。Agent 不是简单的 if-else 自动化而是一个带有不确定性决策能力的系统——这意味着你需要像对待人类同事一样对待它信任但要验证。十条防坑原则聚合为三条永远不要信任 Agent 的输出它说它完成了 ≠ 真的完成了。验证 信任。边界是 Agent 的生命线工具权限、调用次数、API 预算——没有边界的 Agent 不是工具是定时炸弹。可观测性是安全的根基如果你不知道为什么 Agent 做了某个决定那当它做错决定时你也不知道怎么修。自学出身的我特别喜欢 Agent 这个概念——它让编程变得更像对话而不是命令行。但我也更清楚越像人的东西越容易产生人的问题。你会在代码里加 assertion你也应该在 Agent 里加 safeguard。下一篇预告WASM 跨语言互操作的坑字符串编码、内存管理和异步调用的三座大山。