做 Agent 落地做到第 17 个客户的时候,我彻底想明白了一件事:很多团队说自己在做 Agent 工程化,其实只是在做 Prompt 工程。真正把 Agent 推上生产环境,你会撞上一堵墙——Serverless 平台的无状态模型和 Agent 天生需要"有状态长跑"之间的矛盾,以及随之而来的沙箱工程化难题。
这篇文章要说的 AgentRun,就是我在这个背景下设计的一套 Agent 沙箱执行运行时。它能让你在标准的 Serverless 环境中跑起真正完整的 Agent:多轮记忆、工具调用、代码执行、中间产物持久化,一个都不少。适合正在做 Agent 上生产、被平台无状态限制卡住的开发者,也适合准备自建 Agent 执行平台的架构师,文章里所有的设计思路、代码片段和踩坑记录都可以直接拿去参考。
1. Serverless 无状态和 Agent 天生冲突的根源
1.1 Serverless 平台为什么坚持"无状态"
很多人对 Serverless 的理解停留在"不用管服务器",但真正决定你架构的不是这个,而是它底层的执行模型:一次请求进来,平台临时拉起一个执行环境,跑完函数,这个环境随时可能被销毁回收。因为要支撑"缩容到零"和"按量计费"这两件事,平台必须假设你的代码不依赖任何本地状态——内存里的变量、磁盘上的文件、后台的进程,全都不保证存活。
我见过太多团队在这个地方翻车。有个朋友把 Agent 的会话上下文存在函数实例的全局变量里,本地测试一切正常,一上生产就出现"用户问第二句话,Agent 完全不记得第一句"的诡异现象。原因很简单:两次请求可能被调度到两个不同实例上,就算命中了同一个实例,空闲几秒钟后实例被杀掉,内存里的上下文就没了。
这就是无状态的真相:它不是规则,是经济学。平台用"不承诺保留任何东西"换来了弹性和成本优势。你要在这个模型里做 Agent,就不能跟它对着干,不能指望平台给你开个"长期存活"的特权,而是要把需要长期存在的东西全部搬出执行环境。
1.2 Agent 的真实执行模型:根本不是一次函数调用
普通 Web 接口是无状态友好的:请求-响应,一次搞定,中间不需要跨请求记忆。Agent 完全不是这个形态。一个稍微像样的 Agent,收到任务后要经历:理解任务、拆解计划、调用工具、观察结果、修正计划、再调用工具,这中间可能还有一个 10 轮的内部循环。每一轮都要带上完整的上下文,否则模型就会"失忆",推理质量断崖式下跌。
更要命的是,Agent 的很多工具调用不是秒级完成的。代码执行可能要跑几分钟,网页抓取可能卡住重试,外部 API 可能要排队。这意味着一次"用户请求"对应的是一个持续数分钟甚至更长的执行流程,期间会不断产生新的中间状态:已经执行的步骤、已经拿到的结果、已经写好的临时文件、已经消耗的 token 次数。
用传统 Serverless 函数的视角看,这就是一个"超长时间的事务"。函数的生命周期根本盖不住它,无状态的执行环境也存不住它。硬要在一个无状态函数里塞完整 Agent 循环,结果就是要么函数超时被杀,要么状态全丢,要么每步都重新开始。这也是为什么很多团队最终放弃了标准的 FaaS,转而自己维护一台带状态的常驻服务——但那样又失去了 Serverless 的弹性。
1.3 沙箱不是可选项,是 Agent 的刚需
Agent 和普通接口还有一个本质区别:它会执行不可控的代码。不管是 Agent 自己写了段 Python 去处理数据,还是调用终端命令操作文件,这些动作都发生在你无法预判的代码路径里。如果你让 Agent 直接在你的业务服务器上执行这些动作,一个 prompt 注入就可能让你付出惨痛代价。
所以 Agent 执行环境必须沙箱化:独立的文件系统、受限的网络访问、隔离的进程空间、严格的资源配额。这不是"加分项",是安全底线。
但沙箱和 Serverless 天然存在张力。Serverless 给你的是一个"用完即走"的临时容器,而沙箱的价值恰恰在"可持续复用"——它要能装下依赖、留住环境、在一次 Agent 任务的多个步骤之间保持一致。平台给不了你这个保证,你就得自己设计一个沙箱生命周期管理系统。AgentRun 解决的正是这整条链路上的问题。
2. AgentRun 的核心设计:打破无状态魔咒的三个杠杆
2.1 第一个杠杆:把"状态"从运行时里彻底地搬出去
AgentRun 的第一条原则是:运行时只负责算,不负责记。所有需要跨步骤存续的数据,全部外置到独立的状态服务。
这里的"状态"不只是对话历史,而是一个完整的状态对象,大致包含这几类:
- 会话上下文:用户输入、消息历史、当前目标、已完成的子任务。
- 执行状态:当前处于 Agent 循环的哪一步、已执行的工具调用序号、等待中的外部响应。
- 中间产物:Agent 生成的代码、处理过的数据文件、下载的内容。
- 资源状态:已分配的沙箱 ID、已打开的连接、缓存的对象。
我建议把前两类放在 Redis 这类低延迟 KV 里,把第三类大文件放进对象存储(MinIO、S3 均可),因为会话状态高频读写,必须快;中间产物低频访问,按对象存更省成本。
核心结构体长这样:
@dataclass class SessionSnapshot: session_id: str step_index: int messages: list[Message] plan: list[TaskStep] tool_results: dict[str, ToolResult] sandbox_id: str checkpoint_at: int每个关键节点(Agent 每执行完一步工具调用、每次大模型返回后)就把这个快照序列化后写进 Redis。序列化我用 MessagePack,不用 Python pickle——后者有反序列化执行任意代码的风险,在沙箱场景里尤其危险,而且跨语言跨版本都不稳定。
import msgpack import redis class SessionStore: def __init__(self, redis_url: str): self._r = redis.from_url(redis_url) def save(self, snap: SessionSnapshot): payload = msgpack.packb({ "session_id": snap.session_id, "step_index": snap.step_index, "messages": [m.to_dict() for m in snap.messages], "plan": [t.to_dict() for t in snap.plan], "tool_results": snap.tool_results, "sandbox_id": snap.sandbox_id, "checkpoint_at": snap.checkpoint_at }) self._r.hset(f"session:{snap.session_id}", "snapshot", payload) def load(self, session_id: str) -> SessionSnapshot | None: raw = self._r.hget(f"session:{session_id}", "snapshot") if not raw: return None data = msgpack.unpackb(raw) return SessionSnapshot(**data)把状态外置之后,执行环境就真的可以做到"随时销毁、随时重建":新拉起来的沙箱只要从 Redis 里读回快照,就恢复到崩溃前的状态。整个系统的可靠性不再依赖某个容器的存活时长,而是依赖存储的可靠性——存储可比容器稳定得多。
2.2 第二个杠杆:沙箱池化与会话亲和路由
状态搬出去之后,第二个要解决的问题是沙箱本身的创建成本。一个沙箱从镜像启动到依赖就绪,冷启动通常是秒级到十几秒。如果每个 Agent 步骤都新建一个沙箱,延迟和资源开销都不可接受。
AgentRun 的做法是沙箱池化:维护一批常驻的沙箱实例,空闲时回收进池子,请求到来时从池子里直接取用。同时做会话亲和路由——同一个 session_id 的请求,尽量路由到同一个沙箱实例,因为沙箱的文件系统里可能残留了上一步产生的临时文件,换一个沙箱就全没了。
池子的核心逻辑:
class SandboxPool: def __init__(self, min_idle=3, max_total=20): self._idle = deque() self._busy: dict[str, Sandbox] = {} self._min_idle = min_idle self._max_total = max_total async def acquire(self, session_id: str) -> Sandbox: if session_id in self._busy: return self._busy[session_id] sb = None while self._idle: cand = self._idle.popleft() if cand.alive(): sb = cand break await cand.cleanup() if sb is None: if len(self._busy) + len(self._idle) >= self._max_total: await self._evict_oldest() sb = await create_sandbox(session_id) self._busy[session_id] = sb return sb async def release(self, session_id: str): sb = self._busy.pop(session_id) await sb.clean_temp() self._idle.append(sb)注意release不是销毁沙箱,而是清理临时文件后放回空闲池。这样同一个沙箱可以在不同会话之间轮转,但池子会记录它上一次归属的会话,如果原会话很快回来,优先复用。我用一个 LRU 策略:每一次请求都会刷新沙箱的"最近使用时间",池子按这个时间淘汰最久没用的实例。
沙箱池的容量是个需要调参的地方。开太多,资源白占;开太少,冷启动频繁。我一般根据两个数来算:目标并发会话数,和单沙箱空闲回收阈值(比如 5 分钟无会话使用就允许销毁)。池子最小值兜底预热,最大值控制资源上限,剩下的交给动态伸缩。
2.3 第三个杠杆:执行过程的可恢复与幂等
状态外置解决了"存",但没解决"恢复"。Serverless 环境下的最大不确定性是:你的执行过程每一步都可能被中断。函数超时、实例被杀、进程崩溃、网络抖动,任何一个发生,当前这步就没了。
所以 AgentRun 把整个 Agent 执行过程建模成一个可恢复的状态机。每个 Agent 会话在 Redis 里维护一个执行状态:
# 会话执行状态 session:{id} -> { "status": "pending" | "running" | "paused" | "completed" | "failed", "step_index": 12, "queue": ["tool_call:codexec_001", "tool_call:web_search_002"], "retry_count": 3 }每一步执行前,先把"我要做什么"写进队列(预写日志),再真正执行。执行成功后,把结果写入tool_results,然后推进step_index。如果中途挂了,调度器检测到会话心跳超时,就重新拉起一个沙箱,从最后一条预写日志开始重放:已经完成的结果直接读tool_results,不需要真的再调一次工具。
这里必须保证工具调用的幂等性。我给每个工具调用生成一个全局唯一 ID(UUID),工具执行前先检查这个 ID 是否已有结果缓存,有就直接返回,没有才真正执行。这样重放不会导致重复扣费、重复写文件、重复发消息。
async def execute_tool(cls, tool_call: ToolCall): cache_key = f"tool_result:{tool_call.invocation_id}" cached = await store.get(cache_key) if cached is not None: return cached result = await sandbox.run_tool(tool_call) await store.set(cache_key, result, ttl=3600*24) return result这套组合拳打下来,整个系统的形态就变了:Serverless 平台提供的无状态函数只负责"接收请求、调度、转发",真正干活的沙箱变成一个可回收、可重建、可恢复的资源单元。平台继续帮你弹性伸缩,AgentRun 帮你保证状态不丢、过程能续。
3. AgentRun 实战落地:搭建一个能扛并发的 Agent 沙箱服务
3.1 整体架构与组件选型
下面我把 AgentRun 的完整架构摊开讲,这是我实际在用的生产版本。整套系统分五个部分:
| 组件 | 选型 | 职责 |
|---|---|---|
| 接入层 | API Gateway + 消息队列 | 接收用户请求,削峰填谷,异步返回结果 |
| 调度层 | AgentRun Scheduler | 路由会话到沙箱、管理池子、检测心跳、触发恢复 |
| 执行层 | Sandbox Worker 集群 | 跑 Agent 主循环,执行工具调用,按快照恢复 |
| 状态层 | Redis + MinIO | 存会话快照/工具结果/执行队列,存大文件产物 |
| 模型层 | 模型网关(统一 OpenAI 协议) | 封装 LLM 调用,做 key 管理、限流、重试 |
沙箱本身的技术选型我对比过几条路:
- Docker / runc 容器:启动快,生态好,隔离靠内核 namespace,适合可信工具代码。
- gVisor:用户态内核,隔离性更强,适合执行不可信的 Agent 生成的代码。
- Firecracker MicroVM:隔离最彻底,但启动秒级起步,资源开销大,适合多租户隔离要求高的场景。
我的组合是:通用的 Agent 工具调用(搜索引擎、HTTP 请求、文件处理)跑在 Docker 容器里,因为这部分代码是我们自己写的插件,风险可控;Agent 自己生成的代码(比如让 LLM 写个 Python 脚本处理数据)跑在 gVisor 沙箱里,因为模型生成的代码不可预测,必须按不可信处理。这个组合实测下来,能扛住绝大多数实战场景,成本和速度都合理。如果你面向的是金融、政务这类强监管多租户场景,预算充足的话可以直接上 Firecracker。
3.2 状态外置层的核心实现
状态外置层的实现要点有两个:快照的序列化策略,和"写快照"的时机。
序列化策略上面给了 MessagePack 版本,这里补充一个容易踩的坑:不要把大模型生成的中间对象(比如某些 Agent 框架的 memory 对象、工具调用的复杂嵌套结构)直接序列化,而是先转换成纯数据 DTO。我在早期版本里直接存了框架的 MemoryDTO,结果框架一升级,老数据全反序列化失败,线上直接一批会话无法恢复。后来统一加了一层转换器,只存消息列表、步骤列表、结果字典这些基础类型,框架升级不再影响存量数据。
快照写入时机我踩过两次坑之后固定了规则:每个 Agent 步骤完成之后写一次,每次 LLM 调用返回之后写一次,两次工具调用之间如果耗时超过 5 秒也写一次。第一版我只在步骤完成时写,结果一个长耗时工具调用到一半被平台杀掉,重放时发现自己已经花了钱调了 LLM 但这步的上下文没落盘,恢复出来是个半成品状态。改成"关键节点都写"之后,最坏情况也就是损失一个正在进行的工具调用,上一步状态永远在。
大文件产物不能直接塞 Redis,要落到 MinIO,Redis 里只存引用:
class ArtifactStore: def __init__(self, minio_client, bucket="agent-artifacts"): self._mc = minio_client self._bucket = bucket async def save_artifact(self, session_id: str, path: str, data: bytes): obj_name = f"{session_id}/{path}" self._mc.put_object(self._bucket, obj_name, BytesIO(data), len(data)) return obj_name async def load_artifact(self, obj_name: str) -> bytes: resp = self._mc.get_object(self._bucket, obj_name) return resp.read()所有对象名带 session_id 前缀,方便会话结束后按前缀一键清理过期产物。我写了个定时任务,对超过 7 天没访问的会话做归档,30 天直接清空,避免对象存储无限增长。
3.3 沙箱网关与调度逻辑
调度层是最容易写出 Bug 的地方,因为并发模型和普通 Web 服务完全不同。我用了异步事件循环 + 每个会话一个轻量任务队列的方式,避免一个会话的多个请求并发打爆同一个沙箱。
每个会话进来后,Scheduler 先做三步:
- 查 Redis 里会话的
status。如果running,说明上一个执行还没结束,新请求进队列排队;如果failed,先走恢复流程。 - 从池子里
acquire沙箱,绑定到session_id。 - 沙箱启动后从快照恢复,然后开始执行 Agent 循环。
async def handle_request(self, req: AgentRequest): session_id = req.session_id lock = await redis_lock(f"lock:{session_id}", ttl=5) if not lock: # 已经有别的请求在处理这个会话 return self.enqueue(session_id, req) async with lock: snap = await store.load(session_id) if snap is None: snap = SessionSnapshot(session_id=session_id, step_index=0, ...) sandbox = await pool.acquire(session_id) await sandbox.restore(snap) result = await self.run_agent_loop(sandbox, snap, req.user_input) await store.save(result.snapshot) await pool.release(session_id) return result注意分布式锁这里,我用 RedisSET NX EX实现,TTL 设成 5 秒是因为正常一个步骤的执行不会超过 5 秒,超过的话说明卡住了,锁会自动过期,由下一个请求触发恢复流程。不要用无限 TTL 的锁,否则进程崩溃后锁永远不释放,会话就永久卡死。
调度层的弹性伸缩指标我不用 CPU,用"空闲沙箱数量":当空闲池数量低于最小值时,提前创建沙箱;高于最大值时,销毁最久没用的。因为 CPU 在沙箱里经常是空闲的,Agent 主要卡在 LLM 调用上,CPU 指标完全无法反映真实压力。
3.4 一次完整的 Agent 调用流程走读
把整个流程串起来看,一次典型调用是这样的:
- 用户通过 API Gateway 提交任务,请求落到消息队列,立刻收到一个
task_id,可以轮询或订阅 WebSocket 拿结果。 - Scheduler 消费队列里的任务,按
session_id查会话状态,拿到 Redis 锁。 - 从沙箱池
acquire一个沙箱。如果池子里没有空闲的,创建新沙箱(冷启动,同时触发预热任务补一个空闲实例)。 - 沙箱从快照恢复会话上下文,把相关的中间产物从 MinIO 拉回沙箱本地文件系统。
- Agent 主循环开始:构造 prompt(带上完整消息历史)→ 调 LLM → 拿到工具调用指令 → 在沙箱里执行工具 → 工具结果存 Redis → 写快照 → 进入下一轮循环。
- 循环到 LLM 返回最终答案,或者达到最大步数限制。
- 最终结果和最终快照写回 Redis,沙箱清理临时文件,放回池子,任务完成。
这套流程跑通之后,用户感知就是:无论 Serverless 平台怎么杀实例、怎么调度,Agent 任务都能在几十秒到几分钟内完成,中间的所有进度都"记得住"。
4. 生产环境踩坑实录与排查手册
4.1 冷启动是最大的敌人
冷启动在 Agent 场景下比普通 Web 严重得多。普通函数冷启动就拉起一个进程,几百毫秒。Agent 沙箱冷启动要做什么:拉镜像(几个 GB 是常态)→ 起容器 → 装依赖(PyTorch 这种动辄几百 MB)→ 恢复快照 → 建 LLM 连接池。
我实测过一组数据:Python 运行时 + requests + 若干常用库的空容器,从拉镜像到能执行第一个工具,大约 4 到 8 秒;如果镜像里带了一个 Agent 框架和向量索引,直接干到 20 秒以上。这个延迟对交互式 Agent 是完全不可接受的。
我的对策分三层:
- 镜像预热:每次部署后立即在后台把所有 Worker 节点上的"热镜像"拉取一遍,避免真正请求时现场下载。
- 空闲池保底:至少保持 3 个空闲沙箱常驻,响应最差也在秒级。
- 分层加载:镜像拆成基础层(Python + 常用库)和应用层(Agent 框架 + 自定义插件),应用层更新频繁但体积小,基础层稳定且大,这样每次发版不会触发全量拉镜像。
还有一个容易忽略的点:LLM 连接池本身也有冷启动。沙箱恢复后第一次调 LLM,如果 SDK 要走代理或者 OAuth token 认证,可能额外多 1 到 2 秒。我干脆在沙箱创建时就把连接池建好、token 预取好,随沙箱一起存活,复用就不再有这个开销。
4.2 状态一致性与并发冲突
第二个大坑是并发。用户连续发消息、或者回调重试,同一个 session 可能同时进来两个请求。如果没有锁和幂等机制,两个请求各自恢复快照、各自执行,最后互相覆盖,轻则丢消息,重则重复执行工具。
我遇到过最离谱的一次:用户点了两次发送,两个请求同时在沙箱里执行send_email工具,客户收到了两封一模一样的邮件。排查下来就是幂等没做好——我虽然给工具调用加了 invocation_id,但两个并发请求各自生成了不同的 invocation_id,所以缓存判空,两个都执行了。
修复方案:invocation_id 由 Scheduler 生成而不是由沙箱生成,且排队的请求(见 3.3 的enqueue)必须拿到前一个请求的结果之后才能创建新的 Agent 步骤。也就是说,"同一会话同一时刻只允许一个执行流",其他请求在队列里等,这是硬约束。
并发还有一个隐蔽问题:Redis 里的快照和沙箱本地文件系统状态可能不一致。比如快照已经更新到第 10 步,但沙箱里第 11 步刚写到一半就崩溃了,新沙箱恢复的步骤 10 是完整的,但旧的残留文件如果没清干净,可能污染新执行。所以沙箱acquire时必须做一次"重置到快照对应状态"的校验,检查关键目录的指纹(文件列表 + 哈希),不一致就清空重建。
4.3 沙箱安全与资源隔离
安全这块我吃过一次教训才彻底收紧。早期版本让 Agent 在沙箱里可以直接访问外网,结果有个测试任务让模型"搜索某个网站并下载资源",模型被 prompt 注入诱导着去解析内网地址,虽然内网没开放什么敏感服务,但这说明网络隔离必须默认拒绝、按需放行。
现在我所有的工具调用都走一个"安全代理层",规则如下:
- 默认关闭沙箱的出网权限,需要联网的工具(网页检索、API 请求)通过代理授权后才放行,且代理里维护一个域名/网段的 allowlist。
- 沙箱内禁止挂载宿主机任何目录,只挂载一个临时的数据卷,会话结束后清空。
- 所有沙箱以非 root 用户运行,加
--cap-drop ALL和 seccomp 白名单,禁止mount、ptrace这类高危 syscall。 - 每个容器限 CPU(比如 2 核上限)、限内存(4G 上限)、限磁盘写入(500MB),超限直接 OOM 杀进程而不是拖垮宿主机。
很多人觉得 Agent 生成的代码"只是处理数据,不会有危害",但你在沙箱里跑它的时候,它和一段恶意程序没有任何区别。只要模型能力足够,它就能写出读取系统文件、发起网络请求、尝试权限提升的代码。安全隔离不是防模型,是防"模型被外部内容诱导后的行为"。
4.4 常见问题速查表
| 现象 | 可能原因 | 处理措施 |
|---|---|---|
| 会话第二次请求丢失上文 | 状态没外置,存在函数实例内存里 | 全部改用 Redis 快照,函数只当调度器 |
| 沙箱创建后要等十几秒 | 镜像没预热 | 部署后立即拉镜像,空闲池保持 3-5 个 |
| 同一会话重复执行工具 | 并发请求没有锁 | Scheduler 加 Redis 分布式锁 + 排队 |
| 工具结果重复扣费 | 幂等没覆盖所有工具 | invocation_id 由调度层生成,结果缓存 |
| 沙箱经常 OOM | 内存限额过低/模型生成了超大循环 | 调限至 4-6G,加步骤数和执行时间上限 |
| 恢复后状态和文件对不上 | 快照与本地文件不一致 | 恢复时校验文件指纹,不一致则清空重建 |
| Agent 卡住不返回 | LLM 调用长时间无响应 | 模型网关加超时和重试,超时自动降级 |
| 镜像拉取冲突 | 多个沙箱同时拉同一镜像 | 用镜像预热 + 本地缓存层,避免并发下载 |
5. 我的体会与后续还能怎么玩
这套系统上线跑稳定之后,我最大的体会是:Agent 工程化表面上是模型问题,本质上是基础设施问题。模型推理能力再强,如果执行环境给不了"长期记忆 + 安全隔离 + 弹性伸缩"这三件事,一切白搭。而 Serverless 平台天然只给了你第三件,前两件要靠自己设计。
几个小的经验补充:快照别写太频繁,Redis 带宽会被打满,我后来加了"脏标记",只有状态实际变化才落盘;沙箱池子的空闲回收一定要做,不然发布新版本时旧沙箱还跑着旧代码,会出现"同功能两种行为"的诡异现象;日志要按session_id全链路打点,否则线上排一个 40 步的 Agent 任务,你连它走到哪一步崩的都不知道。Agent 执行的每一步都要有审计记录,这个在出安全事故时是救命稻草。
后续我准备在这个方向上继续做三件事:一是给沙箱加自动快照压缩,长会话的上下文会越长越大,得用 embedding + 摘要策略把历史压到合理范围;二是做多 Agent 协作的沙箱拓扑,让不同 Agent 的沙箱之间可以通过受控通道交换数据,而不是靠共享文件系统;三是把 checkpoint 频率改成语义感知的动态策略——模型自己在关键节点声明"这里有必要保存",减少无效快照。每一步都不容易,但方向很清楚:真正的 Agent 平台,拼的就是执行层的工程深度。