智能体与普通 LLM 应用的分界线在于“会行动”:普通应用输出一段文字,智能体调用工具、修改状态、在循环中迭代。这个差别放大了静默失效的风险——一个对话应用答错了,影响止于这一轮对话;一个智能体把错误参数传进了真实工具,影响会落到数据库、发件箱和下游系统里。更麻烦的是,这三类失效都不以异常的形式出现:工具调用“成功返回”了,上下文压缩“正常执行”了,智能体循环“跑完了”,但工程意图在行为层面已经偏离。
几乎所有智能体框架的工具调用路径都遵循同一个模式:把工具的 JSON Schema 交给模型,模型返回一个 tool_calls 数组,框架解析后执行。问题出在“解析后执行”这一步——模型返回的参数在结构上看起来符合 schema,但语义上不保证合法。一个 send_email 工具声明 to: string,模型可能返回 "to": "张三",schema 校验通过,邮件客户端拒绝这个地址,异常在工具内部抛出,而智能体的重试逻辑又用同样的参数再试一次。
工具层的正确设计是把模型输出当作不可信输入,在执行前做一次独立的参数校验,校验失败时返回结构化的错误给模型,而不是把异常抛进工具实现:
from pydantic import BaseModel, EmailStr, ValidationError
class SendEmailArgs(BaseModel):
to: EmailStr
subject: str
body: str
def execute_tool(name: str, raw_args: dict, registry: dict) -> dict:
spec = registry.get(name)
if spec is None:
return {"ok": False, "error": f"unknown_tool:{name}"}
try:
args = spec.args_model(**raw_args)
except ValidationError as e:
return {"ok": False, "error": "invalid_args",
"detail": e.errors(), "retryable": True}
try:
result = spec.fn(**args.model_dump())
return {"ok": True, "result": result}
except Exception as e:
return {"ok": False, "error": "tool_failed",
"detail": str(e), "retryable": spec.retryable}两个关键字段值得注意:retryable 让框架区分“参数错了,重试有意义”和“工具故障,重试会放大影响”;detail 把 Pydantic 的字段级错误原样回传给模型,模型在下一轮能看到“to 不是合法邮箱”,而不是一个笼统的“调用失败”。把校验错误变成模型可读的反馈,是让智能体自我修正的前提;把校验错误变成一个被吞掉的异常,是让智能体在同一个错误上循环的前提。
长对话的智能体必须做上下文压缩,否则 token 预算会在几十轮内耗尽。常见做法是把较早的消息摘要成一段自然语言,保留最近 N 轮原文。这个机制在大多数轮次里工作正常,但它有一个安静的失效模式:摘要会丢掉后续步骤需要的精确细节,而智能体不知道自己丢了。
例如,用户在第三轮说“订单号是 A12345,退款金额 300 元,不要通知客户”,二十轮之后摘要变成“用户申请了一笔退款”。智能体在调用 create_refund 时拿到的是一个没有订单号、没有金额、没有“不通知”约束的上下文。它不会报错,它会基于不完整的信息自信地生成参数。
工程上的缓解不是“不要压缩”,而是把压缩拆成两层:自然语言摘要用于叙述性历史,结构化状态用于事实性信息。关键实体(订单号、金额、用户明确表达的约束)不进入摘要,而是写入一个独立的、每次调用都注入的 state 对象:
import json
def build_context(history: list, summary: str, state: dict) -> list:
state_block = (
"以下是从对话中提取的已确认事实,必须严格遵守:\n"
+ json.dumps(state, ensure_ascii=False, indent=2)
)
messages = [{"role": "system", "content": state_block}]
if summary:
messages.append({"role": "system",
"content": f"此前对话摘要:{summary}"})
messages.extend(history[-6:])
return messages
# state 由每轮结束后的抽取步骤更新,不参与摘要
state = {"order_id": "A12345", "refund_amount": 300,
"notify_customer": False}state 的更新时机也有讲究:它应该在工具调用成功之后更新,而不是在模型声称“已完成”时更新。模型说“我已经退款了”不等于工具返回了成功,把模型的自我陈述写进 state,等于把幻觉固化成了后续所有轮次的事实前提。
智能体的自主性来自循环:思考、调工具、观察结果、再思考。没有显式预算的循环有两个失效方向。一个是步数失控:工具持续返回“部分成功”,模型持续重试,循环在框架的默认上限内耗尽了整轮 token 预算,最终返回一个半成品,而账单已经产生。另一个是非幂等重试:send_email 或 create_order 在网络超时后由框架自动重试,第一次调用其实已经成功,第二次调用产生了重复记录。
两类问题都可以用一个执行包装器来约束,把“步数、成本、幂等键”变成显式参数:
import hashlib, time
class AgentBudget:
def __init__(self, max_steps=12, max_cost_usd=0.50):
self.max_steps = max_steps
self.max_cost_usd = max_cost_usd
self.steps = 0
self.cost = 0.0
self.seen_actions = set()
def check(self, action: dict, cost_delta: float) -> dict:
self.steps += 1
self.cost += cost_delta
if self.steps > self.max_steps:
return {"stop": True, "reason": "step_budget_exceeded"}
if self.cost > self.max_cost_usd:
return {"stop": True, "reason": "cost_budget_exceeded"}
if action.get("side_effect"):
key = hashlib.sha256(
f"{action['tool']}:{action['args']}".encode()
).hexdigest()[:16]
if key in self.seen_actions:
return {"stop": True, "reason": "duplicate_side_effect"}
self.seen_actions.add(key)
return {"stop": False}side_effect 是一个由工具声明决定的标记,duplicate_side_effect 的判定基于“工具名 + 参数”的哈希。这不是一个完美的幂等性方案——两次意图不同但参数相同的调用会被误判——但它把“重复发送”从一个只能在事后从日志里发现的问题,变成了一个在调用前就被拦截的显式决策。真正需要精确幂等的场景,应该把幂等键下沉到工具实现里,由下游系统按 key 去重。
这三个断层的共同逻辑是:智能体的每一步都在把模型的输出转化为系统状态,而模型输出在结构上合法、在语义上不保证正确。工具参数要用独立于 schema 的运行时校验拦一道,上下文压缩要把事实性信息从叙述性摘要里分离出来,循环要把步数、成本和副作用重复放进显式的预算对象里。这三件事都不改变智能体的“智能”,但它们决定了智能体的行为是在“能跑”的层面停留,还是在“可控”的层面成立。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。