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 开发听起来很酷,但实际上是给模型装了一条"思考-行动"循环链,而你永远不知道这条链会通向哪里。

二、十大陷阱全景


三、认知层与工具层陷阱

陷阱 1:Prompt 的无声偏移 —— System Prompt 在对话中慢慢"变质"

/// ❌ 最简单的 Agent 实现 —— 也是最危险的
struct NaiveAgent {
    system_prompt: String,        // "你是一个代码审查专家..."
    messages: Vec<ChatMessage>,   // 用户消息 + 工具调用结果不断追加
}

impl NaiveAgent {
    async fn step(&mut self, user_input: &str) -> Result<Action> {
        // 每轮都把 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: Vec<ChatMessage>,    // 最近 20 条完整的
    conversation_summary: Option<String>,  // 历史对话的压缩摘要
}

impl RobustAgent {
    async fn step(&mut self, user_input: &str) -> Result<Action> {
        // 如果最近消息超过阈值,压缩旧消息
        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)
    }
}

陷阱 2:Agent 忘了自己的目标 —— 沉浸在"探索"中

这就是开头的例子。Agent 被赋予了"读文件"的能力后,把"深入理解代码"当成了目标,而不是手段。

修复方案:在每个工具调用的 prompt 中强调"目标导向"

你是一个代码审查 Agent。你的**唯一任务**是审查给定的 diff。

每当你考虑调用 `read_file` 工具时,先问自己:
1. 这个文件是否在本次 diff 的变更范围内?
2. 不读这个文件,我能否给出有意义的审查意见?
3. 我已经读了多少文件?是否超出了合理范围(上限 5 个)?

如果你的目标是"理解整个代码库",停下来 —— 你的目标是"审查这个 diff"。
/// ✅ 代码层面的防护:工具调用计数器
struct GuardedAgent {
    tool_call_count: HashMap<String, 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: Vec<PathBuf>,  // 只允许删除这些目录下的文件
    require_confirmation: bool,    // 每个删除操作都要确认
    max_file_size: u64,            // 不删除超过 1MB 的文件
}

impl Tool for DeleteFileTool {
    fn description(&self) -> &str {
        "删除临时生成的文件。只能删除 temp/ 和 cache/ 下的文件,
         被系统文件保护。需要用户确认。"
    }
    
    async fn execute(&self, path: &str) -> Result<String> {
        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));
        }
        
        // 硬限制 2:API 费用
        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) -> Result<Output> {
    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: Option<String> = None;
// 两个并发的 Agent 实例会互相覆盖状态

/// ✅ 每个 Agent 实例拥有自己的状态,通过 channel 通信
struct IsolatedAgent {
    state: AgentState,
    tools: HashMap<String, Arc<dyn 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::DateTime<chrono::Utc>,
    iteration: usize,
    action: ActionType,
    reasoning: String,       // 模型输出的推理过程
    tool_used: Option<String>,
    tool_params: Option<serde_json::Value>,
    tool_result: Option<String>,
    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 不允许读取 .envid_rsacredentials 等敏感文件;write_file 只能在 src/tests/ 下创建新文件;execute_command 只允许 cargo checkcargo testgit diff --stat 三个命令。每个工具的定义里都声明了副作用等级,Destructive 级别的操作必须用户二次确认。

**第三层:审计日志 + 自动回放(对应陷阱 10)。**每一轮 Agent 的决策过程(推理文本 + 工具调用 + 返回值)都序列化到 JSON 文件。出了问题时,我能用日志回放重现整个 Agent 的思考过程——就像看一段"心路历程"录像。有一次 Agent 连续三次建议"修改同一个 import 语句",回放发现它每轮读到的文件内容是旧的缓存版本,根本没看到上一轮的修改。修复是在文件读取结果上加了最后修改时间戳的校验。

这三层防护只多了 200 行代码,但把 Agent 的行为从"黑盒赌博"变成了"可控流程"。现在 dayuan 的代码审查 Agent 已经稳定运行了四个月,被准确拦截了 17 次可能失控的操作而没有一次误拦。


五、总结

做 Agent 开发这一年多,我从"哇它能自己思考了"的兴奋,逐渐变成了"每次上线都像拆弹"的谨慎。Agent 不是简单的 if-else 自动化,而是一个带有不确定性决策能力的系统——这意味着你需要像对待人类同事一样对待它:信任但要验证。

十条防坑原则聚合为三条:

  1. 永远不要信任 Agent 的输出:它说它完成了 ≠ 真的完成了。验证 > 信任。
  2. 边界是 Agent 的生命线:工具权限、调用次数、API 预算——没有边界的 Agent 不是工具,是定时炸弹。
  3. 可观测性是安全的根基:如果你不知道为什么 Agent 做了某个决定,那当它做错决定时,你也不知道怎么修。

自学出身的我特别喜欢 Agent 这个概念——它让编程变得更像"对话"而不是"命令行"。但我也更清楚:越像人的东西,越容易产生人的问题。你会在代码里加 assertion,你也应该在 Agent 里加 safeguard。


下一篇预告:WASM 跨语言互操作的坑,字符串编码、内存管理和异步调用的三座大山。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值