从 Demo 到上线:我用 AgentScope 2.0 踩完四道闸,才敢把 Agent 部署成服务
先交代一下背景:我手里有一个基于 AgentScope 2.0 写的 Agent 原型,功能不复杂,就是让大模型调用几个内部工具去处理工单、查知识库、生成回复摘要。原型在 Notebook 里跑得很欢,演示给同事看的时候大家也都说“不错不错”。但真要部署成线上服务的那一刻,问题全冒出来了——没有接口、没有鉴权、没有超时控制、没有日志追踪,最尴尬的是,服务一重启,Agent 的对话上下文全没了。
这篇文章就是我复盘整个改造过程后沉淀下来的东西,核心就一句话:把「能跑的脚本」变成「敢上线的服务」,中间隔了四道闸。我会把这四道闸逐一拆开,结合 AgentScope 2.0 的具体写法,讲清楚每道闸背后要解决什么问题、我是怎么过的、以及踩过的坑。
适合谁看?如果你也正处于“Agent 原型跑通了,但不知道怎么交给别人用、不敢放到生产环境”的阶段,这篇应该能帮你省掉不少试错时间。如果你只是好奇 AgentScope 2.0 到底是什么,也能从里面找到答案——简单说,它是一个用于构建多智能体应用的框架,2.0 版本在消息传递、服务化部署、可观测性上有不少升级,我这次改造基本就是围绕这些能力展开的。
1. 内容整体设计与思路拆解:为什么 Demo 跑通不等于能上线
1.1 Demo 和线上服务的本质差异
在 Notebook 里跑一个 Agent Demo,本质上你是在跟它“当面聊天”——一切状态都在内存里、一切调用都在同一个进程里、一切都依赖你手动去触发。这种模式有四个隐藏假设,而线上环境恰恰不满足它们:
第一个假设是调用者是“你”。Demo 里你用自己的账号、自己的 Key、自己的调试态度去调用 Agent,但上线后调用者是第三方系统或真实用户,他们不会像你一样温柔。第二个假设是进程永远活着。Notebook 里你的 Kernel 不重启,Agent 的上下文就一直存在,但部署成服务后,容器可能随时被杀掉、重启、扩缩容,状态必须能从外部恢复。第三个假设是流量是低频的。Demo 你一次最多同时跑两三个对话,但线上可能有几十上百个并发请求,服务如果没做限流和排队,一个慢请求就能拖垮整个进程。第四个假设是出错了你能马上看到。本地跑的时候,异常直接打在屏幕上,但线上用户报障的时候,你得通过日志和指标去还原现场。
所以,把 Agent 部署成服务,不是给脚本套一层 HTTP 外壳那么简单,而是要从“调用、状态、治理、可观测”四个维度重新设计一遍。这也就是我总结的四道闸:接口闸、状态闸、治理闸、运维闸。
1.2 AgentScope 2.0 在这套设计里的定位
AgentScope 2.0 在官方文档里强调的是“多智能体应用的最后一公里”,其实就是帮你把 Agent 从“模型调用脚本”升级成“可交互的应用”。我实际用下来感受是,它最值钱的部分有这几个:
- 消息传递机制:2.0 把 Agent 之间的交互抽象成了统一的消息对象,不管你是让 Agent 调工具、调另一个 Agent、还是等用户输入,都走同一套消息协议。这让服务化和状态管理变得相对统一。
- 工具调用与回调:Agent 调用外部工具(查库存、发邮件、调内部 API)时,工具的执行结果会作为新消息重新喂回模型,形成一次完整的“感知-决策-行动”闭环。
- 可嵌入的执行环境:2.0 支持你以编程方式初始化 Agent 运行时,而不是只能通过命令行交互。这意味着你可以把 AgentScope 当库一样装进后端服务代码里——这正是我能把它改造成 HTTP 服务的基础。
我们后面每一道闸的代码示例,都是基于这个“AgentScope 2.0 可嵌入执行环境 + 业务侧补全服务化能力”的思路来写的。
1.3 我为什么坚持“网关模式”而不是“直接暴露 Agent 对象”
其实最开始我有一个更激进的方案:直接让 AgentScope 的运行时监听端口,把 Agent 对象本身暴露成 RPC 服务。这样连胶水代码都不用写,Agent 的每个方法都能远程调。
但想了一下放弃了,原因有两条。一是 Agent 内部的执行是有状态且多轮的,直接把 Agent 对象暴露出去,等于把“内部实现细节”也暴露了出去,任何一个调用者都能拿到 Agent 的内部状态,这非常不好控制。二是业务侧往往需要对 Agent 的输入输出做额外的校验、格式化、鉴权,这些逻辑放哪里?只能塞进 Agent 内部,那 Agent 就变成了“业务万能胶”,后续想复用就难了。
所以我采用了一个轻量网关模式:外面是一层薄薄的 HTTP 接口层,里面包着 AgentScope 的 Agent/Tool 业务逻辑,中间通过“会话服务”来管理状态。HTTP 接口层负责协议和数据校验,业务逻辑层只专注 Agent 的推理和工具调用,会话服务负责状态的读写和恢复。这样每层各干各的,出了问题也好定位。
2. 第一道闸:把“直连调用”改造成“标准服务接口”
2.1 接口层设计:别急着上代码,先想清楚这几个问题
我见过很多人的第一个版本接口是这样写的:
@app.post("/agent") def run_agent(request: dict): result = my_agent(request["message"]) return {"reply": result}能跑,但完全不敢上线。问题在于:接口的输入输出没有 schema 约束,调用方传什么你都接;没有错误码,模型超时、工具报错、限流拒绝全都混成一个 500;没有幂等控制,调用方重试一次就可能造成重复发邮件或重复扣款。
我当时给自己定了四个设计约束:
- 输入必须结构化。至少包含
session_id(会话标识)、message(用户消息)、metadata(可选的附加信息,比如用户等级、来源渠道)。 - 输出必须带状态和错误模型。一次返回除了业务数据,还要有
status字段标明这次是正常完成、还是被限流、还是模型超时,让调用方不用靠猜的。 - 接口要支持同步和异步两种模式。Agent 执行通常要几秒到十几秒,同步接口方便上层调试,异步接口方便生产流量控制。后面我会详细讲怎么实现。
- 版本号进 URL。用
/v1/agent/completion而不是/agent,以后改协议的时候不用原地爆炸。
2.2 用 FastAPI 封装 AgentScope Agent:一个可以“抄作业”的最小实现
我选 FastAPI 的原因很简单:它在 ASGI 基础上封装好了参数校验(Pydantic)、接口文档(Swagger UI)、并发处理,正好覆盖接口闸需要的那几块能力。AgentScope 本身不绑定 Web 框架,所以这一层就是我们自己的代码。
下面是一个简化但完整的版本。这个代码做三件事:定义输入输出的数据模型;初始化 AgentScope 的 Agent;把 Agent 的执行封装进 HTTP 接口。
from fastapi import FastAPI, HTTPException, Header, Depends from pydantic import BaseModel, Field from typing import Optional, Dict, Any import uuid import time # AgentScope 相关 from agentscope.agent import AgentBase from agentscope.runtime import Runtime # ---------- 数据模型 ---------- class AgentRequest(BaseModel): session_id: Optional[str] = Field( default=None, description="会话标识,不传则服务端自动生成" ) message: str = Field(..., max_length=4000) metadata: Optional[Dict[str, Any]] = Field(default_factory=dict) class AgentResult(BaseModel): session_id: str status: str # success / blocked / error / timeout reply: Optional[str] = None error_code: Optional[str] = None latency_ms: int = 0 # ---------- 运行时初始化 ---------- runtime = Runtime() # 这里以自己的 Agent 初始化代码为准 # agent = runtime.create_agent("customer_service", model_config={...}) # ---------- 网关层 ---------- app = FastAPI(title="Agent Service", version="1.0.0") @app.post("/v1/agent/completion", response_model=AgentResult) async def agent_completion(req: AgentRequest): start = time.time() if req.session_id is None: req.session_id = str(uuid.uuid4()) try: # 这里把 AgentScope 的调用包装成一个普通函数 reply = await run_agent_with_tools(req.session_id, req.message, req.metadata) return AgentResult( session_id=req.session_id, status="success", reply=reply, latency_ms=int((time.time() - start) * 1000) ) except AgentToolError as e: return AgentResult( session_id=req.session_id, status="error", error_code=e.err_code, latency_ms=int((time.time() - start) * 1000) ) except Exception: raise HTTPException(status_code=500, detail="internal error")这段代码虽然基础,但它已经具备了“接口闸”的核心骨架:结构化的输入输出、错误码、耗时统计。别嫌它简单——你后续加鉴权、限流、审计,都是在这一层上挂中间件,而不是改 Agent 内部逻辑。
2.3 同步 vs 异步:不是炫技,是流量模型决定的
Agent 服务的响应时间天然比普通 REST 接口长。一个带工具调用的 Agent 执行流程经常是:用户发消息 → 模型推理出要调工具 → 工具执行(可能 1~3 秒)→ 结果喂回模型 → 模型再生成回复。整体在 3~10 秒之间很常见。
如果你的调用方是浏览器里的用户,他们等 5 秒还行,但你的网关层如果用的是同步阻塞模型,5 秒内这个连接一直占着资源。更重要的是,同步阻塞在高并发下会把服务的线程池打满,其他所有请求都被堵住。
我实际上线时的做法是双轨制:
/v1/agent/completion保持同步接口,但只面向内部可信调用方(比如后台管理员调试、内部系统触发),并且有严格的超时和并发上限。/v1/agent/tasks走异步任务模式:请求进来先返回task_id,Agent 在后台队列里执行,调用方通过/v1/agent/tasks/{task_id}轮询结果。
异步模式的代码核心是使用 FastAPI 的BackgroundTasks或独立的 Celery 任务队列,把 AgentScope 的执行逻辑放到 worker 里。这里有个实用经验:无论同步还是异步,Agent 执行的超时控制一定不能漏。大模型调用有时候会被卡住(网络抖动、上游服务慢),一个永不超时的 Agent 任务最后会变成“僵尸任务”,占着 worker 不释放。
我习惯给整个 Agent 执行链路设置一个总超时,通常 30 秒,然后给单次模型调用设置一个更短的超时,比如 15 秒。实现上可以用asyncio.wait_for包住整个执行:
import asyncio try: reply = await asyncio.wait_for( run_agent_with_tools(session_id, message, metadata), timeout=30 ) except asyncio.TimeoutError: return AgentResult( session_id=req.session_id, status="timeout", error_code="AGENT_TIMEOUT", latency_ms=30000 )2.4 接口闸的隐蔽雷区:序列化和数据精度
这一节是踩坑心得。Agent 的输入输出内容在 AgentScope 里本质上是消息对象,对象里可能包含非 JSON 序列化的内容,比如中文字符、特殊符号、图片链接、或者模型返回的 Markdown 格式文本。你如果直接返回原始对象给前端,轻则格式不对,重则整个响应就 500 了。
我踩过的具体的坑有两个。第一个是JSON 编码问题,Agent 生成的文本偶尔会出现特殊的 Unicode 字符,比如\u2028(行分隔符),有些浏览器端解析直接白屏。第二个是数字精度问题,工具返回的结果里如果有浮点数(比如库存量、价格),直接透传可能导致调用方解析精度丢失。解决方式是在网关层加一个统一的“响应净化器”,把所有文本内容做编码修正,所有数字类型显式声明精度或统一换成字符串。
另外一个细节:接口的幂等性。如果你的 Agent 有一次调用了“发送邮件”工具,调用方因为网络超时重发了同一请求,后果就是用户收到两封邮件。我在接口层加了一个request_id幂等键,网关会在缓存中记录这个request_id对应的结果,重复请求直接返回缓存。
3. 第二道闸:给 Agent 装上“记忆”——会话状态与持久化管理
3.1 为什么 AgentScope 默认会话状态会丢
AgentScope 2.0 的 Agent 在多轮对话中的行为本质上是:每一轮它都会接收“历史消息 + 新消息”,然后决定下一步动作。在 Demo 里,历史消息都存在内存里,你跑一个交互式会话,上下文一直都在。
但部署成服务后,情况完全不一样。你的服务进程可能有多个 worker(多进程),请求 A 落在了进程 1,请求 B 落在进程 2,如果进程 2 没有进程 1 的上下文,Agent 就会“失忆”,而且用户会明显感觉到前言不搭后语。更别说服务重启、容器重建、版本升级,内存里的一切直接清零。
所以第二道闸的核心目标很明确:让 Agent 的会话状态独立于进程而存在。状态要能读写、要能恢复、还要考虑并发写时的冲突。
3.2 状态要存什么:不止是消息历史
最自然的想法是“把聊天记录存进 Redis”,但实际做的时候你会发现,Agent 的会话状态比聊天记录要丰富得多:
- 多轮消息历史:用户消息、Agent 回复、工具调用记录、工具执行结果。
- Agent 内部变量:有些场景下 Agent 会在多轮中维护一些中间状态,比如用户填表走到哪一步了、购物车里加了什么。
- 正在执行的任务状态:如果 Agent 正在等一个异步工具的结果,这个“等待状态”也需要能恢复,否则超时重试的时候会重复执行工具。
所以我的状态模型拆了三层:
- 基础会话存储:用 Redis 存最近 N 条消息(我固定存最近 50 条),键是
session_id,值是压缩后的 JSON 数组。 - 持久化归档:把每一轮的完整消息(包括工具内部日志)异步写入数据库(我用的是 PostgreSQL),用于审计和回溯。这一步不阻塞主流程。
- 并发锁和版本号:每次更新会话状态时带上版本号,如果版本冲突说明有并发写,就强制让后到的请求重新读取最新状态再追加消息。
3.3 用代码实现会话恢复
我自己实现了一个简单的会话管理器,用 Redis 存消息,用版本号控制并发。核心逻辑是:每次请求进来先 Load 历史消息,追加用户消息,执行 Agent,拿到回复后 Save 回存储。
import redis import json import time class SessionManager: def __init__(self, redis_url: str, max_history: int = 50): self.r = redis.Redis.from_url(redis_url) self.max_history = max_history def load_messages(self, session_id: str) -> list[dict]: key = f"session:{session_id}" raw = self.r.get(key) if raw is None: return [] data = json.loads(raw) return data.get("messages", []) def save_messages(self, session_id: str, messages: list[dict]) -> None: key = f"session:{session_id}" # 只保留最近 N 条,避免状态无限膨胀 messages = messages[-self.max_history:] payload = json.dumps({"messages": messages, "updated_at": time.time()}) self.r.set(key, payload) # 设置过期时间,比如 7 天没有活跃的会话自动清理 self.r.expire(key, 7 * 24 * 3600)实际接入 AgentScope 的时候,你需要在每次 Agent 执行前把历史消息重新组装成一个“对话上下文”传给 Agent。不同的 Agent 实现方式可能不一样,有的是直接把消息列表传给 Agent 的构造函数,有的是通过runtime的接口设置上下文。我自己是把 SessionManager 封装成 Agent 外部的一个 callback,在 Agent 每次执行前注入历史,执行后回写。
3.4 一个容易被忽略的问题:会话状态的“容量上限”和“过期策略”
Agent 的历史消息不能无限长。一方面模型有 context window 限制,另一方面每次请求把历史全部传给模型,Token 消耗也吃不消。我在项目里做了动态裁剪:
- 简单规则:保留最近 50 条消息,更早的直接丢弃。
- 进阶规则:如果历史消息里含工具调用,工具调用及其结果是成对出现的,裁剪时不能只砍一半,否则上下文语义就断了。所以我的裁剪是“按工具调用的完整轮次”为单位来做,要么都留,要么都丢。
还有一个很重要但容易漏的东西:会话过期策略。我见过很多人的 Redis 会话 key 永远不过期,服务跑了半年,Redis 里堆满了僵尸会话。一定要设 TTL。我默认是 7 天,具体看你业务场景。
3.5 多副本部署时的会话路由策略
解决了状态存储之后,又有一个新问题:如果你部署了多个服务副本(多实例),同一个用户的请求可能被负载均衡器分到不同实例。虽然会话状态在 Redis 里是共享的,但 Agent 执行过程中如果有本地缓存(比如模型调用的结果缓存、工具结果的临时缓存),跨实例就会不一致。
我当时有两个选择。一个是做session 亲和性(sticky session),让同一个 session 的请求尽量落到同一个实例。另一个是所有状态全部外置,本地不保存任何与业务相关的东西。我的结论是:为了让系统有弹性,能让实例随时重启而中断用户会话,必须走全外置。Sticky session 只是短期缓解,不能解决重启的问题。所以我在 AgentScope 的执行逻辑里,强制所有可变状态都通过 SessionManager 读写,Agent 内部不维护任何实例级缓存。
4. 第三道闸:面向真实流量的“治理”——限流、鉴权、超时与重试
4.1 没有治理的 Agent 服务,就是裸奔
接口有了,状态能保存了,是不是就能上线了?当然不行。你可能觉得“我这是个内部系统,流量不大,不需要限流”,但我可以说一个真实案例:我们有个 Agent 服务因为一个调用方代码出 bug,在循环里疯狂重试,把模型 API 的额度几分钟内打光了,账单直接爆掉。从那以后我就养成了一个习惯:任何服务上线前,限流、鉴权、超时、重试四件事必须有一个可执行的方案。
4.2 限流:令牌桶算法怎么选参数
限流的实现方式很多,令牌桶、滑动窗口、漏桶,各有适用场景。我用的是令牌桶,因为它的行为最符合 Agent 服务的流量特点:允许短时间的突发流量,但长时间平均速率受控。
具体参数怎么定?我给一个自己用的经验公式。先估算你的模型 API 的 QPS 上限,再留出 30% 的安全余量。比如你的模型服务允许 100 QPS,那你的限流阈值可以设为 70 QPS。其次,还要限制并发执行中的 Agent 任务数,这更重要——因为一个 Agent 任务执行 5 秒,它占用的资源远比一个普通的 HTTP 请求大。我一般会用信号量控制全局并发 Agent 任务数,默认是 20,这个数要根据你的机器性能和模型时延来调。
FastAPI 限流的实现可以用slowapi或自己写一个中间件。核心代码如下:
from slowapi import Limiter from slowapi.util import get_remote_address limiter = Limiter(key_func=get_remote_address) app.state.limiter = limiter @app.post("/v1/agent/completion") @limiter.limit("20/minute") async def agent_completion(request: Request, req: AgentRequest): ...但这里有个坑:如果你把限流挂在 HTTP 层,那同一时间处于执行中的 Agent 任务可能还是很多,因为 HTTP 请求进来了不代表 Agent 执行结束了。所以我会在 Service 层再做一层并发控制:
import asyncio agent_semaphore = asyncio.Semaphore(20) async def run_agent_with_semaphore(session_id, message, metadata): async with agent_semaphore: return await run_agent_with_tools(session_id, message, metadata)两层叠加的效果是:HTTP 层限流挡住无序的突发请求,Service 层的信号量保证 Agent 执行资源的可控。
4.3 鉴权:不要自己发明认证协议
Agent 服务上线后,你需要考虑谁有权限调用。我见过一些小团队自己发明了一个“简单 token”方案——把 token 直接写在 URL 参数里。这种方案的问题在于 URL 会被日志记录、被代理服务器缓存、被浏览器历史记录,等于把钥匙挂在门口。
我自己的方案是标准做法:API Key + 签名。调用方在 Header 里带上X-API-Key和X-Timestamp,服务端校验 Key 是否存在、是否有权限,再校验时间戳是否在 5 分钟内,防止重放攻击。如果对安全要求高,可以再加一个 HMAC 签名,把请求体一起签进去,但这个对调用方要求比较高,内部系统看情况用。
实际上我们最后用的是另一个方案:因为 Agent 服务大部分请求来自内部系统,这些系统之间已经有一个统一的服务认证平台,所以我直接在网关层接入了已有的 SSO / Token 校验逻辑,而不自己再造一套。这里想说的是:如果没有现成的认证体系,API Key 就够用了;如果有,优先复用,别自己发明。
鉴权层还有一个职责是识别调用方身份。同一个 Agent 服务可能被多个业务方调用,不同业务方可能应该看到不同的工具集(比如 A 业务不能调用 B 业务的内部接口)。所以我在鉴权之后,会把调用方身份写进请求上下文,在 Agent 执行时用它来决定工具路由。
4.4 重试和超时:要防“重试风暴”
超时是必要的,但重试要非常谨慎。模型调用超时后你可以重试一次,但如果策略是“超时后立刻重试”,高流量时就会形成重试风暴:一堆请求同时超时、同时重试,把下游压得更垮。
我最后定下的策略是:
- 第一次调用模型不设重试,超时就返回错误或降级提示。
- 工具调用可以对幂等工具重试一次(比如查询类工具),但写操作类工具(发邮件、扣款)绝不自动重试。
- 如果必须自动重试,采用指数退避,第一次等 1 秒、第二次等 2 秒、第三次等 4 秒,最多三次。
还有一点,鉴权失败的错误响应不重试,限流拒绝的响应看你心情(一般建议等 1 秒再重试,但如果调用方都卡着限流阈值了,重试只会雪上加霜)。
4.5 降级方案:Agent 不可以用时,你的服务不能跟着挂
上线前我给自己提了一个问题:如果模型 API 挂了,我的 Agent 服务是直接报错,还是给用户一个体面的降级回复?后来我把降级方案做成了三层:
- 模型 API 单次调用失败且重试也失败 → 返回内置的兜底回复,比如“抱歉,我暂时无法处理你的请求,请稍后再试”;
- 整个 Agent 执行链路超时 → 同样的兜底回复,但状态字段标记为
timeout; - 如果降级次数太多(比如连续 10 次都失败) → 网关熔断,直接拒绝新请求,不让流量继续打到模型 API 上。
这个方案虽然简单,但在线上救过我很多次。Agent 服务本质上还是依赖大模型的,模型是不可控的第三方依赖,必须把它当作“随时可能挂的外部依赖”来设计。
5. 第四道闸:敢上线的前提——可观测性、优雅上线与版本发布
5.1 没有日志和追踪,线上排障就像瞎猜
前面三道闸解决的是“功能能不能用”的问题,第四道闸解决的是“出了问题能不能快速找到原因”的问题。我把这一部分统一叫“运维闸”,它包含三块内容:结构化日志、调用链追踪、监控告警。
先说日志。我见过最原始的做法是把 Agent 的中间过程直接print到标准输出,但生产环境里这完全是灾难。线上流量是杂乱的,没有 trace 关联,你根本分不清日志里哪几行是同一次请求。
我的做法是:在网关层生成一个trace_id,贯穿整个请求链路。从收到 HTTP 请求开始,到 Agent 执行、模型调用、工具调用、最终返回,每一步都打结构化日志,带上trace_id、session_id、event_type、latency_ms、token_usage等字段。日志统一输出为 JSON 格式,采集到日志平台之后,可以按trace_id过滤出一次请求的完整生命周期。
代码示例:
import logging import json class AgentLogger: def __init__(self): self.logger = logging.getLogger("agent_service") def log_event(self, trace_id, session_id, event_type, **kwargs): record = { "time": time.time(), "trace_id": trace_id, "session_id": session_id, "event_type": event_type, **kwargs } self.logger.info(json.dumps(record, ensure_ascii=False))这里有必要说一个容易忽略的点:结构化日志别把用户的敏感信息打进去。Agent 的输入里经常包含业务信息,有些甚至是私人数据。日志如果全量记录,既是个安全隐患,又增加了日志平台的存储压力。我的策略是:日志只记录消息的长度、事件类型、耗时、状态码,不记录消息正文。如果需要回放消息内容,去查 PostgreSQL 的会话归档表,而且那表也要做权限控制和加密。
5.2 OpenTelemetry 与 AgentScope 的集成方式
如果你们的系统已经有比较成熟的链路追踪体系,建议直接上 OpenTelemetry(简称 OTel)。OTel 在 Python 生态里有标准化的tracing接口,你在 FastAPI 层可以通过中间件自动生成 Span,然后在 AgentScope 的工具调用和模型调用处手动埋点。
我的埋点策略分三个粒度:
- HTTP 入口 Span:记录整个请求的处理时间、状态、请求大小;
- Agent 执行 Span:记录 Agent 从收到消息到返回回复的总耗时,里面可以挂“模型推理次数”“工具调用次数”等属性;
- 工具调用 Span:每个工具单独一个 Span,记录工具名、入参大小、出参大小、耗时、错误信息。
这三个粒度基本覆盖了排障时的所有需求:用户说“我的请求很慢”,先看 HTTP 入口 Span 到底慢在哪;如果慢在 Agent 执行,继续下钻看是模型调用慢还是工具调用慢。
5.3 优雅上线:别用“kill -9”的方式发布
部署 Agent 服务有一个容易被忽视的问题:Agent 任务不能被打断。普通的无状态 HTTP 服务,发布时杀掉旧版本、拉起新版本,流量很快就切过去了。但 Agent 任务往往在一个 HTTP 请求生命周期之外还有后台执行(异步模式),如果发布时直接 kill 进程,正在执行的 Agent 任务会被硬生生打断,状态写到一半,Redis 里的会话数据可能不完整。
所以我做了这样几件事:
- 容器的
preStop钩子:在停止容器前,先通知服务“我要下线了”,服务接到通知后不再接收新请求,同时等待正在执行的 Agent 任务完成(最多等 60 秒); - 任务队列的幂等恢复:异步任务如果没执行完就被打断,worker 重启后要能检测到这些“脏任务”,要么重新执行(只限幂等任务),要么标记失败并通知调用方;
- 发布顺序:先发布新版本,等新版本健康检查通过,再摘掉旧版本流量。这本质上就是蓝绿发布,我用的是简化版:两套环境(蓝绿或者金丝雀),流量切一部分观察,没问题再全量。
5.4 健康检查与监控指标:你的服务是“活着”还是“真的能干活”
部署平台一般都会配置 HTTP 健康检查(比如/health),但默认的/health只检查进程是否活着,不检查依赖是否健康。我见过一个惨案:进程活着,但 Redis 连接池已经耗尽、模型 API 的 Key 已经被限流,健康检查依然返回 200,负载均衡器把请求照常送进来,用户全部报错。
我的做法是让/health变成一个“深度健康检查”,它除了检查进程状态,还会做这三件事:
- 检查 Redis 连接是否可用(发一个
PING指令); - 检查模型 API 的连通性(只做一个轻量请求,或者检查缓存中最近一次调用的状态);
- 检查关键工具服务(比如内部 API 依赖)的连通性。
任何一个不通过,健康检查就返回 500,负载均衡器就会自动摘除这个实例。同时我把每个依赖的连通状态打成一个指标,比如agent_dependency_health{name="redis"} 1/0,接到监控告警平台,任何依赖抖动都能第一时间发现。
5.5 环境配置管理:不要在代码里写死任何 Key
这个说起来像是基本功,但我真的见过有人把 API Key 写在代码里然后推到 Git 仓库的。Agent 服务涉及到的密钥很多:模型 API 的 Key、内部的工具服务凭证、Redis 的密码、数据库的连接串。我的原则是:
- 所有配置通过环境变量注入,代码里只留占位符;
- 启动时做一次配置校验,缺少必要配置直接启动失败并报出是哪个配置缺失,而不是跑起来之后用了一个空字符串;
- 敏感配置(Key、密码)使用独立的配置中心或密钥管理服务,研发环境用本地
.env文件但只存非敏感配置。
这里有一个细节:在容器环境里,环境变量的注入路径不同,我建议把“配置加载”封装成一个独立模块,统一处理默认值、类型转换和缺失校验,避免每个服务各自为政。
6. 从头到尾的完整改造实录:一个客户支持 Agent 是怎么过闸的
6.1 改造前:Notebook 里的“裸奔”版本
为了让你有整体感,我拿一个具体的项目实例把前面所有内容串起来。假设我们要做一个“客户支持 Agent”,它的职责是:用户发来一句售后问题,Agent 判断这是什么类型的问题(退换货、物流、发票),然后查内部工单系统,生成一个处理建议回复给用户。
改造前,代码长这样:
# demo_notebook.py from agentscope.agent import AgentBase agent = AgentBase( model="qwen-plus", tools=[query_order_status, create_after_sale_ticket] ) while True: user_input = input("用户: ") reply = agent(user_input) print("Agent:", reply)它没有任何接口、没有状态、没有鉴权、没有日志。在 Notebook 里演示完全没问题,但你不可能把这个东西交给业务系统调用。
6.2 改造后:同一个逻辑,四道闸全过
改造后,这个 Agent 服务的架构大概是这样的:
代码结构:
agent_service/ ├── app.py # FastAPI 网关层 ├── session_manager.py # Redis 会话管理 ├── agent_runner.py # AgentScope 执行封装 ├── tools/ # 工具定义,含幂等控制和超时 ├── middlewares/ # 鉴权、限流、日志中间件 ├── config.py # 环境配置加载 └── Dockerfile关键行为变化:
- 用户消息从 HTTP 进入,网关层做了鉴权、限流、参数校验;
- Agent 执行前从 Redis 加载历史消息,组装上下文;
- Agent 调用工具时,每个工具都带超时、幂等控制、独立日志;
- 执行结果写回 Redis,并异步归档到 PostgreSQL;
- 整个生命周期输出结构化日志,包含 trace_id 和 session_id;
- 如果模型 API 异常,返回兜底回复并标记状态。
6.3 测试和压测:过闸的“质检”环节
改造完成后的上线前测试我分了四轮:
- 功能测试:覆盖常规对话、多轮上下文、工具调用、异常输入。这一轮主要验证逻辑正确性。
- 并发压测:用 Locust 模拟 50 个并发用户,每个用户连续发 20 条消息,重点观察 Redis 连接数、模型 API 延迟、Agent 执行时长。压测出来的数据直接决定你的限流阈值和 worker 数量。
- 故障演练:手动停掉 Redis、让模型 API 返回超时、杀掉 worker 进程,看服务是否能优雅降级或恢复。这一步很多团队不做,但真出事的时候才知道值不值。
- 安全扫描:检查依赖漏洞、接口是否有 SQL 注入/命令注入风险(Agent 如果调用了 shell 工具,这个必须查)。
压测报告里我最关心的三个指标是:P50/P95/P99 响应时间、错误率、模型 Token 消耗。如果 P95 到 10 秒以上,说明 Agent 执行链路有瓶颈,需要优化工具调用或模型推理策略。
6.4 在线上的某次真实故障排查实录
分享一次我遇到的真实问题。上线后某一天,监控告警突然提示错误率飙升。我先看日志,发现异常集中在“工具调用超时”这个事件上。继续下钻,发现是工单系统的接口变慢了——他们的数据库 CPU 满了,所有请求的响应时间从 200ms 变成了 5 秒。
我们服务的工具调用超时是 5 秒,结果 5 秒内拿到不到响应,全部超时。表面看是“工具超时”在报错,但根因是下游依赖变慢。如果我没有工具调用级别的埋点,我只能看到“Agent 服务整体变慢”,排查范围会大得多。这就是我反复强调“日志要有 trace_id、Span 要有粒度”的原因。
最后修复的方式是:在工单系统恢复前,我们在配置中心临时把该工具的超时调大了一些,同时给 Agent 加了一个策略——工具超时后,Agent 直接向用户回复“工单系统暂时不可用,请您稍后再试”,而不是让整个会话死掉。
7. 常见问题与排查技巧实录
7.1 常见问题速查表
| 问题现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| Agent 回复“失忆”,前言不搭后语 | 会话状态未持久化或读不到 | 查 Redis 中 session_id 对应 key 是否存在;查 SessionManager 读取逻辑 | 确认每次请求都正确 Load/Save 消息 |
| 请求偶尔变慢,但不是每次都慢 | 多个请求共享 Agent 执行信号量,排队等待 | 看采样日志里的semaphore_wait_ms指标 | 调大并发限制或增加 worker 实例 |
| 模型 API 返回 429 限流 | 调用频率超过模型服务配额 | 查 Token 消耗日志、模型调用次数 | 网关层加模型 API 级别的限流和退避 |
| 服务重启后正在执行的异步任务丢了 | 没有任务恢复机制 | 查任务队列中的未完成任务状态 | 实现任务幂等恢复,重新入队或标记失败 |
| Redis 内存暴涨 | 会话 key 过多且没有过期 | 查 Redis 的 keyspace 分布 | 设 TTL、裁剪历史消息、定期清理僵尸会话 |
| 工具调用重复执行(如重复发邮件) | 缺少幂等控制,调用方重试了请求 | 查工具日志中是否有同一 request_id 的重复记录 | 在网关和工具层都做幂等控制 |
7.2 我踩过的一些“查不出来”的坑
分享几个在文档里绝对不会写的问题。
第一个是 Redis 连接池耗尽。Agent 服务每个请求都要读写 Redis,如果你的 SessionManager 每次都新建连接,并发一上来 Redis 连接数就爆了,然后所有 Redis 操作都阻塞。后来我用了连接池,并设置了合理的 max_connections。这个从外部看很难排查,因为报错可能五花八门——有可能是Timeout reading from socket,也有可能是Connection pool exhausted。
第二个是模型 Context 超长导致报错。Agent 多轮对话后,历史消息长度慢慢增长,终有一天会超模型上下文上限。我加了一个“消息裁剪”逻辑,但早期版本的裁剪是直接砍最老的消息,导致 Agent 丢掉关键的上下文信息(比如用户一开始交代的需求背景),行为变得很奇怪。后来改成“摘要式裁剪”:如果历史消息太长,就送给一个轻量模型做一次摘要,然后把摘要作为最早的上下文保留下来。
第三个是健康检查的短路。我们当时健康检查会检查模型 API 的连通性,但如果模型 API 连续几分钟不稳定,健康检查一直返回 500,负载均衡器把这个实例摘了,新实例一直起不来,结果整个服务不可用。后来我把健康检查改成“只检查最近 1 分钟内模型 API 的成功率”,而不是实时探测。这样模型 API 抖动不会导致整个集群自杀式摘除。
第四个是异步任务的回调丢失。如果你用异步模式,任务执行完需要通知调用方,而回调这个动作本身可能失败。我见过的情况是任务执行成功了,但回调没发出去,调用方一直轮询,最后等不到结果。我的解决方案是:任务执行成功后,结果直接保存到 Redis 里,轮询方通过/v1/agent/tasks/{task_id}查询,而不是依赖回调通知。回调只是加速感知的手段,不是唯一的消费路径。
7.3 上线的最后一道检查清单
最后分享一个我每次上线前都会过一遍的检查清单。它不复杂,但每条都对应一个真实的线上事故:
- [ ] 接口是否有限流?限流阈值是否基于压测数据?
- [ ] 每个工具调用是否有超时和错误透传?
- [ ] 会话状态是否能跨实例、跨重启恢复?
- [ ] 模型 API 的 Token 消耗是否有预算监控?
- [ ] 结构化日志是否能按 trace_id 串起完整链路?
- [ ] 健康检查是否覆盖了关键依赖?
- [ ] 发布流程是否有 preStop 等待期,是否会打断正在执行的 Agent 任务?
- [ ] 是否设定了降级策略,模型不可用时用户会看到什么?
- [ ] 有没有自动化回归用例覆盖核心链路?
这些你都确认完了,再把流量切进来,心里就有底了。
写在最后的一个实操心得
如果你现在手头有一个 Agent 原型要上线,我的建议是别急着写代码,先拿这篇的四道闸框架过一遍现状:接口有吗、状态存了吗、流量能挡住吗、出问题你能知道吗?逐条对着看,你会发现改起来其实不难,难的是意识到“原来这些问题都算问题”。
我自己的体会是,Agent 服务化这件事,真正花时间的不是某一道闸的技术实现,而是你愿不愿意把一个“能跑的脚本”当成“一个要长期运行、被很多人依赖的线上系统”来对待。后者需要的所有严谨和妥协,正好就是这四道闸想帮你补齐的东西。