从 Demo 到生产级 Agent:8 个关键设计机制与 Python 实现拆解

大部分 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 的核心是一个循环:模型输出 → 解析 → 如果有工具调用就执行 → 把结果塞回上下文 → 再问模型。这个循环必须有硬性退出条件,否则模型可能反复调用同一个工具,或者在一段推理里绕圈。

我一般会同时设三层限制:

  1. 最大步数(max_steps),比如 15 步
  2. 单次请求的总超时(wall-clock timeout),比如 90 秒
  3. 重复调用检测:同一个工具 + 同一组参数连续出现两次,直接判定为循环

前两个好理解,第三个容易被忽略。举个场景:模型调用 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 出问题最难受的地方是"不知道它为什么这么做"。它调用了三次搜索,改了两次参数,最后给了一个莫名其妙的答案。如果没有日志和追踪,你只能靠猜。

最小可用的可观测性包括三件事:

  1. 每一步的结构化日志:步骤号、类型、耗时、输入输出摘要
  2. 会话级别的 trace_id,串起所有步骤
  3. 关键指标:步数分布、工具调用成功率、平均耗时、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 步也算失败?这个定义直接影响你怎么设阈值、怎么评估。如果你有更清晰的思路,欢迎交流。

相关推荐
I Am a robert girl1 小时前
当传感器学会“说谎“:拆解可靠性门控的稀疏惯性动捕融合
python·姿态估计·传感器融合·惯性动捕·imu传感器·可靠性门控·可穿戴计算
架构技术专栏1 小时前
交叉熵:AI 怎样给概率预测打分
后端
匠测AI说1 小时前
AI for Testing 提效实战·测试设计(二):让 AI 按等价类 + 边界值把用例补全
人工智能·测试覆盖率
CHEEVEN_QY1 小时前
液冷板热阻计算与流阻优化的实战方法
人工智能·算法·机器学习
李航19831 小时前
AI定制柜建模,需要详细的建模规范和标准流程
人工智能·python·计算机视觉·ai·ai编程
能源革命1 小时前
AI 日报 · 2026-10-03
人工智能
hahaha60161 小时前
色彩恒常性概述
人工智能·嵌入式硬件·数码相机·计算机视觉
FL16238631292 小时前
无人机视角海边沙滩垃圾检测数据集VOC+YOLO格式603张3类别
人工智能·yolo
Zguigo2 小时前
【CUDA6】CUDA Stream 是什么,为什么 CUDA 是异步执行,如何正确测量 GPU 时间以及多个任务如何重叠执行
人工智能·pytorch·深度学习