
做 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(fsession:{snap.session_id}, snapshot, payload) def load(self, session_id: str) - SessionSnapshot | None: raw self._r.hget(fsession:{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_idle3, max_total20): 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不需要真的再调一次工具。这里必须保证工具调用的幂等性。我给每个工具调用生成一个全局唯一 IDUUID工具执行前先检查这个 ID 是否已有结果缓存有就直接返回没有才真正执行。这样重放不会导致重复扣费、重复写文件、重复发消息。async def execute_tool(cls, tool_call: ToolCall): cache_key ftool_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, ttl3600*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要落到 MinIORedis 里只存引用class ArtifactStore: def __init__(self, minio_client, bucketagent-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(flock:{session_id}, ttl5) 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_idsession_id, step_index0, ...) 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 平台拼的就是执行层的工程深度。