大部分 Agent 教程停在"能跑通"这一步:定义一个工具,写个 while 循环,调用大模型,解析输出,结束。本地跑几次没问题,就以为可以上线了。
真放到生产环境,问题会一个接一个冒出来:模型偶尔返回一段无法解析的文本,循环卡住不动,某个工具超时把整个请求拖死,用户重试导致同一个操作执行两次,上下文越堆越长最后超出模型窗口,账单在某个下午突然翻了好几倍。
这些问题不是模型能力问题,是工程设计问题。下面这 8 个机制,是我认为从 Demo 跨到生产必须补齐的部分。代码用 Python 写,尽量不依赖特定框架,方便你移植到自己的技术栈里。涉及具体库版本的地方我会写清楚,不确定的地方会明确说明。
一、状态持久化:别把 Agent 的命交给内存
Demo 里最常见的写法是把对话历史和中间步骤放在一个 list 里,进程一重启就全没了。生产环境里,用户可能等 30 秒才回来继续对话,也可能同时开三个会话,甚至服务本身会滚动重启。
所以第一步是把 Agent 的状态外置。状态的粒度可以粗可以细,最粗的是只存消息历史,最细的是把每一步的思考、工具调用、观察结果都存下来。我倾向于后者,因为出问题时能回放。
一个最简的状态结构大概是这样:
python
# Python 3.10+
from dataclasses import dataclass, field, asdict
from typing import Any
import json
import time
@dataclass
class AgentStep:
step_id: int
type: str # "thought" | "tool_call" | "tool_result" | "final"
content: Any
created_at: float = field(default_factory=time.time)
@dataclass
class AgentState:
session_id: str
steps: list[AgentStep] = field(default_factory=list)
status: str = "running" # running | done | failed | cancelled
created_at: float = field(default_factory=time.time)
def to_json(self) -> str:
return json.dumps(asdict(self), ensure_ascii=False)
@classmethod
def from_json(cls, raw: str) -> "AgentState":
data = json.loads(raw)
data["steps"] = [AgentStep(**s) for s in data["steps"]]
return cls(**data)
存储层可以是 Redis、Postgres,甚至本地 SQLite。选哪个取决于你的并发量和一致性要求。Redis 快但需要自己处理持久化策略,Postgres 稳但写入延迟高一些。这点没有银弹,按业务量来。
【关键结论】状态外置之后,Agent 就变成了无状态服务,可以水平扩容,也可以做断点续跑。这一步不做,后面所有机制都会打折扣。
二、循环与步数控制:防止 Agent 陷入死循环

Agent 的核心是一个循环:模型输出 → 解析 → 如果有工具调用就执行 → 把结果塞回上下文 → 再问模型。这个循环必须有硬性退出条件,否则模型可能反复调用同一个工具,或者在一段推理里绕圈。
我一般会同时设三层限制:
- 最大步数(max_steps),比如 15 步
- 单次请求的总超时(wall-clock timeout),比如 90 秒
- 重复调用检测:同一个工具 + 同一组参数连续出现两次,直接判定为循环
前两个好理解,第三个容易被忽略。举个场景:模型调用 search("天气") 返回空,它可能原封不动再调一次。加一个简单的哈希去重就能拦住:
python
import hashlib
def tool_call_signature(tool_name: str, args: dict) -> str:
raw = f"{tool_name}:{json.dumps(args, sort_keys=True)}"
return hashlib.md5(raw.encode()).hexdigest()
class LoopGuard:
def __init__(self, max_steps: int = 15, max_repeat: int = 2):
self.max_steps = max_steps
self.max_repeat = max_repeat
self.seen: dict[str, int] = {}
self.steps = 0
def check_step(self):
self.steps += 1
if self.steps > self.max_steps:
raise RuntimeError(f"超过最大步数 {self.max_steps}")
def check_tool_call(self, name: str, args: dict):
sig = tool_call_signature(name, args)
self.seen[sig] = self.seen.get(sig, 0) + 1
if self.seen[sig] > self.max_repeat:
raise RuntimeError(f"检测到重复工具调用:{name}")
这里 max_repeat=2 的意思是允许模型重试一次,第二次再出现相同调用就中断。设成 1 会太激进,因为工具本身可能因为网络抖动失败一次,重试是合理的。
【踩坑提醒】步数上限不要设得太小。设成 5 步,稍微复杂一点的任务就完不成;设成 50 步,出问题时你要等很久。经验值在 10~20 之间,具体看任务复杂度。这个数字我没有严谨的基准测试支撑,属于工程经验判断。
三、工具调用容错:模型输出的东西不一定合法
模型返回的 JSON 经常有各种小毛病:多一句解释、少一个引号、把 null 写成 None、参数类型不对。如果直接 json.loads,一个字符错误就让整个请求失败。
处理方式分两层。第一层是解析容错,尽量从文本里抠出 JSON;第二层是参数校验,用 schema 检查类型和必填项。
python
import json
import re
from typing import Callable
JSON_PATTERN = re.compile(r"\{.*\}", re.DOTALL)
def safe_parse_tool_call(text: str) -> dict | None:
"""从模型输出里尽量抠出工具调用 JSON"""
try:
return json.loads(text)
except json.JSONDecodeError:
pass
match = JSON_PATTERN.search(text)
if not match:
return None
try:
return json.loads(match.group())
except json.JSONDecodeError:
return None
参数校验用 pydantic 会比较省事,v2 版本(当前主流是 2.x)的写法:
python
from pydantic import BaseModel, ValidationError
class SearchArgs(BaseModel):
query: str
top_k: int = 5
def validate_args(raw: dict, schema: type[BaseModel]) -> BaseModel | None:
try:
return schema(**raw)
except ValidationError as e:
# 把错误信息回传给模型,让它自己改
return None
校验失败时,不要直接报错终止。更好的做法是把错误信息作为一条 observation 塞回上下文,让模型自己修正。这通常比抛异常更有效,因为模型看到"query 字段缺失"往往能自己补上。
【注意】不要无限让模型重试修正。一般给两次机会,两次还不行就降级到兜底逻辑或直接返回失败。
四、超时与取消:一个慢工具能拖垮整条链路
生产环境里,任何一个外部调用都可能变慢:搜索 API 抖动、数据库锁等待、第三方服务限流。如果 Agent 没有超时机制,一个请求可能挂几分钟,占着 worker 不放。
Python 里做超时,asyncio.wait_for 是最直接的方式:
python
import asyncio
async def call_tool_with_timeout(
tool_fn: Callable,
args: dict,
timeout: float = 10.0,
):
try:
return await asyncio.wait_for(tool_fn(**args), timeout=timeout)
except asyncio.TimeoutError:
return {"error": f"工具执行超时({timeout}s)"}
超时之后返回一个结构化的错误,而不是抛异常,这样 Agent 循环可以继续,模型有机会换一个策略。
取消(cancellation)是另一个维度。用户点了"停止",或者上游请求断开,Agent 应该能收到信号并停止后续步骤。在 asyncio 里,取消一个 task 会抛出 CancelledError,需要确保工具函数里没有忽略它的地方,否则取消会失效。
【关键结论】超时和取消是两个不同的东西。超时是"等太久了主动放弃",取消是"外部要求停止"。两者都要处理,不能只做一个。
五、可观测性:出问题时你得知道发生了什么

Agent 出问题最难受的地方是"不知道它为什么这么做"。它调用了三次搜索,改了两次参数,最后给了一个莫名其妙的答案。如果没有日志和追踪,你只能靠猜。
最小可用的可观测性包括三件事:
- 每一步的结构化日志:步骤号、类型、耗时、输入输出摘要
- 会话级别的 trace_id,串起所有步骤
- 关键指标:步数分布、工具调用成功率、平均耗时、token 消耗
python
import logging
import uuid
logger = logging.getLogger("agent")
def log_step(trace_id: str, step: AgentStep, duration: float):
logger.info(
"agent_step",
extra={
"trace_id": trace_id,
"step_id": step.step_id,
"type": step.type,
"duration_ms": int(duration * 1000),
"content_preview": str(step.content)[:200],
},
)
content 只打前 200 个字符,避免日志爆炸。完整的输入输出可以存到对象存储或专门的 trace 系统里。
如果团队已经在用 OpenTelemetry,可以直接把 Agent 的每一步做成 span,和现有的后端链路打通。这点我没有在超大流量场景下验证过,但在中小规模下是可行的。
【踩坑提醒】不要在日志里打印完整的 prompt 和模型输出,尤其是涉及用户数据的时候。脱敏和截断是必须的。
六、幂等与重试:用户重试不等于要执行两次

用户看到请求超时,很自然会点重试。如果 Agent 的操作有副作用(下单、发邮件、写数据库),重试就可能导致重复执行。
解决思路是幂等键。客户端每次请求带一个 request_id,服务端记录已处理的 request_id 和对应结果。重试时如果发现这个 id 处理过,直接返回缓存结果,不再执行。
python
class IdempotencyStore:
def __init__(self):
self._cache: dict[str, Any] = {}
def get(self, request_id: str) -> Any | None:
return self._cache.get(request_id)
def set(self, request_id: str, result: Any):
self._cache[request_id] = result
生产环境里 _cache 要换成 Redis 之类的共享存储,并且设置合理的过期时间。太短起不到作用,太长会占内存。
重试本身也要有策略:指数退避、最大次数、只对可重试的错误重试。模型返回格式错误可以重试,但"用户余额不足"这种业务错误重试没意义。
七、上下文管理:窗口不是无限的

Agent 跑十几步之后,上下文会变得很长:历史消息、工具定义、每一步的观察结果全堆在里面。超出模型窗口就直接报错,没超出也会让推理变慢、变贵。
处理方式有几种,我一般组合使用:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 滑动窗口 | 实现简单 | 可能丢关键早期信息 | 短对话 |
| 摘要压缩 | 保留语义 | 摘要本身有信息损失 | 长对话 |
| 关键信息提取 | 精准保留 | 需要额外规则或模型调用 | 结构化任务 |
| 向量检索召回 | 容量大 | 引入检索延迟和误差 | 知识密集型 |
对于 Agent 场景,我倾向于"保留系统提示 + 最近 N 步 + 对早期步骤做摘要"。因为 Agent 的早期步骤往往是探索性的,语义价值不如最近几步。
python
def compress_history(steps: list[AgentStep], keep_recent: int = 6) -> list[AgentStep]:
if len(steps) <= keep_recent:
return steps
early = steps[:-keep_recent]
recent = steps[-keep_recent:]
summary = AgentStep(
step_id=-1,
type="summary",
content=f"前 {len(early)} 步的摘要:..." # 实际调用模型生成
)
return [summary] + recent
摘要生成本身要调用模型,所以要么异步做,要么在步骤数超过阈值时才触发,避免每步都调。
【关键结论】上下文管理不是"删掉旧的"这么简单,关键是判断哪些信息对当前决策还有用。这一步做不好,Agent 会在长任务里逐渐"失忆"。
八、成本与权限边界:别让 Agent 花光你的预算
最后一个机制经常被放到最后才想,但它其实应该在最开始就设计。
成本方面,需要限制的维度包括:单次会话的最大 token 消耗、单个用户单位时间的调用次数、单个工具的调用频率。超出阈值时降级或拒绝,而不是继续烧钱。
权限方面,Agent 能调用的工具应该是最小集合。一个只做问答的 Agent 不应该有写数据库的权限。工具的参数也要校验,比如文件路径不能带 ..,SQL 不能是 DROP。
python
class BudgetGuard:
def __init__(self, max_tokens_per_session: int):
self.max_tokens = max_tokens_per_session
self.used = 0
def consume(self, tokens: int):
self.used += tokens
if self.used > self.max_tokens:
raise RuntimeError("会话 token 预算已耗尽")
权限校验最好放在工具执行的入口,而不是散落在各个工具函数里。这样新增工具时不容易漏掉。
【注意】预算和权限的阈值不要拍脑袋定。先跑一段时间收集真实数据,再根据 P95、P99 来设。设太紧会误伤正常请求,设太松等于没设。
这些机制怎么组合起来
上面 8 个机制不是并列的,它们之间有依赖关系。状态持久化是基础,没有它,取消、幂等、断点续跑都做不了。可观测性贯穿始终,没有它,其他机制出问题时你很难定位。成本和权限是边界,决定了 Agent 能走多远。
一个实际的 Agent 执行循环大概长这样:
python
async def run_agent(state: AgentState, guard: LoopGuard, budget: BudgetGuard):
while state.status == "running":
guard.check_step()
# 1. 压缩上下文
context = compress_history(state.steps)
# 2. 调用模型
response = await call_llm(context)
budget.consume(response.usage.total_tokens)
# 3. 解析工具调用
tool_call = safe_parse_tool_call(response.content)
if tool_call is None:
state.status = "done"
break
# 4. 重复检测
guard.check_tool_call(tool_call["name"], tool_call["args"])
# 5. 执行工具(带超时)
result = await call_tool_with_timeout(
TOOLS[tool_call["name"]], tool_call["args"], timeout=10
)
# 6. 记录状态
state.steps.append(AgentStep(len(state.steps), "tool_result", result))
# 7. 持久化
await save_state(state)
return state
真实系统里还要加上异常处理、重试、权限校验、trace 上报,但骨架就是这样。
写在最后
这 8 个机制没有哪个是"高级技巧",都是后端工程里的老思路:超时、重试、幂等、限流、日志、状态外置。只是 Agent 这个形态把它们的必要性放大了,因为循环是模型驱动的、输出是不确定的、工具是外部依赖。
我的建议是,不要一次全上。先做状态持久化和步数控制,这两个投入小、收益直接。然后补可观测性,因为后面调优全靠它。成本和权限放在上线前做,避免出事故。剩下几个根据你的实际痛点逐步加。
有一个问题我到现在也没有特别好的答案:Agent 的"失败"该怎么定义。是没给出答案算失败,还是给了错误答案算失败,还是答案对了但过程花了 20 步也算失败?这个定义直接影响你怎么设阈值、怎么评估。如果你有更清晰的思路,欢迎交流。