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, ¶ms).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 不允许读取 .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 跨语言互操作的坑,字符串编码、内存管理和异步调用的三座大山。

522

被折叠的 条评论
为什么被折叠?



