去年下半年到今年,我一直在折腾一个比较“重”的方向:把 Agent 从“一次性调接口”变成“真正能24小时值守”的持久化运行环境。试过LangChain、Dify这些框架做编排,也自写过调度器,最后落地的组合是WeKnora + CubeSandbox,把知识检索和代码执行都拆出去,围绕“持久化”这件事做了一套自己的运行时。这篇文章我不想讲概念,就把我踩过的坑、最终跑通的方案和几个关键参数选择,原原本本分享出来。
说下背景:我这边的业务需求是让多个AI Agent轮流上岗,处理文档问答、数据清洗、报表生成这类长期任务。早先用裸Jupyter跑脚本,用Redis存会话,发现两个致命问题——一是Agent一旦执行到一半崩溃,状态全丢;二是Agent生成的代码要在服务端裸跑,安全隐患非常大。所以才有了基于CubeSandbox做隔离执行环境、用WeKnora做知识增强的这套架构。
这篇内容适合正在做Agent工程化、被“对话级Demo”和“生产级Agent”之间那道鸿沟折磨过的开发者和技术负责人。你会看到:为什么需要持久化运行环境、WeKnora和CubeSandbox在架构里各自扛什么活、怎么从零把运行时搭起来,以及我在真实部署中遇到的十几个问题。
1. 为什么Agent需要一套“持久化运行环境”
1.1 说句实话:Agent开发最容易翻车的三个地方
先聊个基础问题:为什么普通的Agent Demo在本地跑得通,一到线上就废?我总结下来是三个地方翻车。
第一是上下文爆炸。Agent一轮一轮地跟LLM对话,历史消息全部塞进Prompt,很快就把上下文窗口撑爆。有人做过统计,Agent连续跑30轮之后,单是靠历史消息拖累,响应延迟至少增加两倍,Token成本涨四倍,而且越到后面模型越容易把早期信息忘掉。这不是模型能力问题,是记忆管道的设计问题。
第二是状态管理缺失。市面上很多Agent框架把会话存在内存里,进程一重启,全没了。但生产环境里,Agent经常要跑长任务——比如深夜做一个跨多表的ETL,跑一半网络抖动,重试可以;可如果任务状态、中间结果、执行到哪一步这些信息没有持久化,重试就得从头再来,这在真实业务里根本不能接受。
第三是工具执行不可控。Agent必然要调工具,最常见的就是让它生成代码、执行脚本。那么在宿主机上裸跑?权限、进程、文件系统、网络全都暴露给它,遇到一个稍微激进点的工具链,轻则把环境搞乱,重则被注入恶意命令。很多团队不敢上Agent,卡就卡在这个安全问题上。
1.2 “持久化”到底指什么:状态、记忆、生命周期
我理解的Agent持久化运行环境,不是简单把数据存进数据库,它至少要覆盖三层:
第一层是运行状态持久化。Agent的任务队列、当前执行步骤、重试次数、依赖的资源句柄,这些都要落盘。类比一下,这就像你打游戏的时候存档——不是说打完这一关才需要存,而是每过一个节点都要存。Agent跑一个三步任务时,执行完第一步把整个Runtime对象序列化进Redis,什么时候挂了,都能从最近的一个检查点拉起来。
第二层是记忆持久化。这里面要区分短期记忆和长期记忆。短期记忆是当前任务会话里的上下文摘要,可以存向量库;长期记忆是跨会话沉淀下来的用户偏好、领域规则、历史决策,放关系型数据库或者独立的记忆服务。我现在做的方案里,短期记忆走WeKnora的向量检索做上下文召回,长期记忆直接落PostgreSQL,按用户ID+场景ID打标。
第三层是生命周期管理。Agent不是“永远活着”,它应该像云函数一样有明确的生命周期:空闲挂起、定时唤醒、任务结束后回收。没有这一层,一个Agent常年占着一块显存或者一个执行进程,资源效率会低得吓人。持久化运行环境的价值,就是让Agent可以“像服务一样被拉起和释放”,而不是“像脚本一样跑完就死”。
1.3 为什么选择WeKnora + CubeSandbox这个组合
如果只是要一个能跑的Agent,那LangChain走天下就够了,没必要自研。但我的判断是:框架负责的是“编排逻辑”,而运行环境负责的是“让Agent稳定活着的底座”,这两件事不能混在一起。
选WeKnora,是因为我希望知识检索这块独立出来。WeKnora这类RAG引擎,擅长把非结构化的文档切片、做知识抽取、生成向量索引,再通过检索API给Agent提供上下文。如果直接用LangChain自带的Retriever,你会发现文档一多,检索质量和查询性能很难平衡,而且知识库跟Agent代码耦合在一起,换框架等于重写一遍检索逻辑。把WeKnora独立部署成一个知识服务,Agent通过HTTP或者SDK去问它拿上下文,整个架构干净很多。
选CubeSandbox,则是我对Agent安全边界的坚持。Agent执行代码和普通API调用不一样,它没法保证每次生成的东西都安全可读。CubeSandbox给我的核心价值,是把代码执行放进一个隔离容器里,能限制内存、CPU、网络、文件读写,执行完自动销毁。我见过太多人问“Agent生成的Python代码出错怎么办”,其实第一步就应该问“Agent的代码到底在哪儿跑”——在宿主机上裸跑,出了事再怎么修都没意义。
再说组合的优势。WeKnora负责“让Agent有知识”,CubeSandbox负责“让Agent能动手且不闯祸”,中间我自己写了一个轻量调度层,负责把两者串起来,并且给每个Agent配一份可持久化的运行时档案。这个组合跑了两三个月,最大的感受是:知识检索和代码执行彻底解耦后,Agent的稳定性上了一个大台阶。知识库调整不影响执行链路,沙箱策略升级也不影响知识检索,出故障时定位问题范围小很多。
2. 整体架构与核心模块设计
2.1 架构分层:业务层、Agent层、执行沙箱层、知识层
我最后定下来的分层结构,大致是四层,每层职责很明确。
业务层就是上游的各个业务系统,比如工单系统、IM机器人、定时任务平台。它们不直接面对Agent的内部逻辑,只是发出一批任务:“处理这个Excel并输出汇总”“根据这个文档回答客户问题”,通过消息队列把任务交给下一层。
Agent层是核心,我在这里管理每个Agent的配置、会话状态、提示词模板、记忆归档。这一层对所有外呼请求做编排,决定是否要检索知识、是否要执行代码、是否要调用外部API,同时维护一个“状态机”,记录当前Agent处于哪个阶段。
执行沙箱层,也就是CubeSandbox。Agent在这里执行Python脚本、Shell命令等会动到计算资源的操作。调度层把任务提交给沙箱,沙箱返回执行结果、标准输出、退出码,以及资源消耗统计。所有执行过程留痕。
知识层,以WeKnora为核心,负责文档解析、向量化、检索排序,对外提供两个关键接口:一个是检索接口,传问题返回相关片段;一个是知识管理接口,用来上传文档、建索引、更新知识库。Agent的记忆也有一部分落在这里,短期记忆用向量检索召回。
这个分层最大的好处,是每一层都能独立扩缩容。知识库检索慢了,单独给WeKnora加副本;Agent任务并发上来了,Agent层多起几个Worker;沙箱资源紧张了,给CubeSandbox集群扩容。四个层之间全部走API通信,没有共享内存,没有共享文件,隔着边界互不信任,这是Agent系统能长期稳定运行的前提。
2.2 WeKnora在持久化环境中的三个角色
在通常的科普文章里,WeKnora只是被写成“RAG检索”,但在我这套持久化环境里,它其实承担了三个角色。
角色一:知识检索。这是最基础的能力。Agent遇到陌生问题,先向量化提问,从文档库里召回到Top K相关片段。我在WeKnora里配置了混合检索,关键词+向量召回,再经过一个Rerank模型排序。实测下来,单靠向量检索命中率大概78%,加上关键词混合能到86%,再上Rerank能稳定在91%上下。这个提升对Agent的答案质量非常关键——召回不准,后续所有推理都是空中楼阁。
角色二:短期记忆的载体。每个Agent会话,我会把每一轮的关键信息做成一个“记忆点”,存成向量。下一轮Agent需要回忆之前说过什么时,不是把整个历史翻出来,而是用当前问题去做向量召回,只把最相关的过去的几轮决策拿出来做参考。这就像人脑的联想记忆,不是回忆整本书,而是想起那几页跟当下有关的段落。这个思路能极大减少Token消耗,也能让Agent在长会话里保持焦点。
角色三:语义缓存。有些高频问题,比如“怎么提交报销单”“服务器的登录IP是多少”,每周被问上百次。完全不用每次让LLM从头推理,我可以在WeKnora里做一层语义缓存:新问题进来,先计算它的向量跟已缓存问题的相似度,超过阈值(我一般设0.92以上)直接复用历史答案,不再调用大模型。这一招帮我把大模型调用成本降了大概30%,对重复性咨询类Agent效果特别明显。
2.3 CubeSandbox的核心价值:把任意工具装进隔离箱
我再把CubeSandbox这部分讲透一点。我的使用场景里,它承载的并不只是“执行用户生成的Python代码”这么简单,它要做四件事。
资源限制。每个沙箱实例有独立的CPU配额、内存上限、执行时长上限。我给大部分Agent配的是单实例2核CPU、2GB内存、单个任务最长300秒,很重要的原因是避免一个失控任务把整台机器拖死。资源限制不是拍脑袋设的,我统计了跑了3000多个任务的执行记录,95%的Agent脚本在2GB内存内能完成,300秒足够覆盖绝大部分数据清洗任务,只有极少数长任务会单独调高配额。
网络隔离。默认情况下,沙箱里的代码不能访问外网。Agent要调外部API时,必须显式声明“允许访问的外部域名白名单”,否则请求直接拦截。这是我最强调的一条安全线,因为很多安全事件都是通过依赖包或脚本里的隐藏逻辑往外传数据。白名单机制本质上就是“最小权限”,需要什么才开什么。
文件系统快照与销毁。每个沙箱实例启动时是一套干净的隔离文件系统,任务结束后可以读回输出文件和日志,然后整个实例立即销毁。这保证了上一个任务的垃圾不会污染下一个任务,也防止Agent写出的临时文件悄悄堆积在服务器上。
执行留痕。每一次执行,从提交的代码、启动的命令、标准输出、错误堆栈到退出码,全部记录成日志。当Agent行为出现异常,我可以回放整个过程,快速定位是哪一步、哪段代码出了问题。不要小看这个能力,没有留痕的Agent系统就像没有黑匣子的飞机,出了问题只能靠猜。
3. 实操:从零搭建一套可复用的持久化运行环境
3.1 环境准备:本地部署WeKnora
我们走一遍完整的搭建过程。第一步是把WeKnora在本地跑起来。我用Docker Compose部署,主要包含三个服务:Web服务、向量数据库、关系型数据库。一个最小化的docker-compose.yml文件大致长这样:
version: "3.8" services: weknora-api: image: weknora/weknora:latest ports: - "8080:8080" environment: - KNORA_DB_HOST=postgres - KNORA_VECTOR_HOST=pgvector - KNORA_LOG_LEVEL=info depends_on: - postgres - pgvector postgres: image: postgres:15 environment: - POSTGRES_USER=knora - POSTGRES_PASSWORD=knora_pass volumes: - pg_data:/var/lib/postgresql/data pgvector: image: pgvector/pgvector:pg15 environment: - POSTGRES_USER=knora - POSTGRES_PASSWORD=knora_vec volumes: - vec_data:/var/lib/postgresql/data volumes: pg_data: vec_data:部署完之后,需要做两件事:上传业务文档并建立索引,拿到一个API Key用于后续的Agent调用。
我踩过的第一个坑是向量索引的切分粒度。WeKnora默认切片可能按256个Token切,但对技术文档来说,这个粒度太碎,语义容易断。我最后用的是512~768个Token的切片,重叠128个Token,检索效果和Token开销最平衡。别小看这个参数,切片太短导致检索片段不完整,切片太长又容易混进不相关内容,我调了两天才找到最合适的值。
启动后建议先验证一下检索接口:
curl -X POST "http://localhost:8080/api/v1/search" \ -H "Authorization: Bearer <YOUR_API_KEY>" \ -H "Content-Type: application/json" \ -d '{"query": "报销单怎么提交", "top_k": 5}'如果这个接口响应在300ms以内,并且返回的片段跟问题高度相关,说明部署成功。如果发现返回比较慢,优先查看向量索引的覆盖情况,很多新手上来就怪服务慢,其实是文档根本没建好索引。
3.2 Agent运行时核心:状态存储与任务调度
WeKnora就绪后,我们回到Agent持久化运行环境的主体部分。我没有选现成的Agent框架做这层,而是自己写了一个轻量调度器,因为我对状态控制的要求比较精细。核心组件有三个:任务队列、状态存储、运行时Worker。
任务队列我用的是Redis Stream。相比普通List,Stream有几个优势:支持消费者组、可以ack确认、有死信机制,天然适合任务系统。每个任务我用一个JSON来定义:
{ "task_id": "task_20250612_001", "agent_id": "agent_doc_qa", "session_id": "session_8891", "type": "doc_qa", "input": { "question": "2024年公司差旅报销标准是什么?" }, "status": "pending", "created_at": 1718132400000, "attempts": 0 }状态存储用Redis + PostgreSQL双写。Redis保存Agent当前执行的短时状态,比如当前步骤、待处理消息;PostgreSQL保存完整的事件流和审计日志。每次Agent完成一个步骤,就在PostgreSQL里追加一条事件记录;Redis里只保留最新状态用于快速读取。这个设计借鉴了事件溯源(Event Sourcing)的思想——从事件流可以随时重建任意时间点的运行时状态,掉线不再需要从头重跑。
调度器我用Python写了一个常驻服务,简单示意如下:
# runtime_scheduler.py import redis import json import time r = redis.Redis(host="localhost", port=6379, decode_responses=True) STREAM_KEY = "agent_tasks" GROUP_NAME = "scheduler_group" def build_group(): try: r.xgroup_create(STREAM_KEY, GROUP_NAME, id="0") except Exception: pass # group already exists def dispatch_loop(): build_group() while True: # 从阻塞队列取任务 messages = r.xreadgroup(GROUP_NAME, "worker_1", {STREAM_KEY: ">"}, count=1, block=3000) if not messages: continue for stream, entries in messages: for msg_id, fields in entries: task = json.loads(fields["payload"]) # 标记开始执行 task["status"] = "running" save_state(task) # 调用Agent的核心入口 agent_result = run_agent(task) if agent_result["ok"]: task["status"] = "done" r.xack(STREAM_KEY, GROUP_NAME, msg_id) else: task["attempts"] += 1 if task["attempts"] >= 3: task["status"] = "dead" else: # 重新入队,保留原消息ID便于追踪 r.xdel(STREAM_KEY, msg_id) save_state(task)这个代码很简单,但里面埋了几个设计点:xack表示任务已经成功执行,不会重复消费;attempts超过三次就进入死信状态,后面人工介入。我建议所有Agent任务都必须具备这个不重不丢的属性,否则在长尾场景下任务丢失会非常让人头疼。
3.3 对接CubeSandbox:代码生成、提交、结果回收
Agent的核心能力之一是写代码解决问题,我们的架构里这个动作是交给CubeSandbox执行的。这里展示我封装的一个调用客户端,它把“组装执行请求、提交沙箱、超时处理、结果回收”四步打包:
# sandbox_client.py import requests class CubeSandboxClient: def __init__(self, base_url="http://sandbox-api:9090", token=""): self.base_url = base_url self.token = token def run_python(self, code: str, cpus: float = 2.0, memory_mb: int = 2048, timeout_s: int = 300) -> dict: resp = requests.post( f"{self.base_url}/v1/executions", headers={"Authorization": f"Bearer {self.token}"}, json={ "runtime": "python:3.11", "command": f"python /workspace/main.py", "files": { "main.py": code }, "resources": { "cpus": cpus, "memory_mb": memory_mb, "timeout_s": timeout_s }, "network": { "enabled": False } }, timeout=timeout_s + 30 ) if resp.status_code != 200: return {"ok": False, "error": resp.text} resp_json = resp.json() return { "ok": resp_json["exit_code"] == 0, "stdout": resp_json["stdout"], "stderr": resp_json["stderr"], "exit_code": resp_json["exit_code"], "duration_ms": resp_json["metrics"]["duration_ms"] }这个调用流程我做了几个防御性设计:
默认不开网络。如果Agent的代码本身不涉及外网访问,我全部设置network.enabled=False。涉及外部API的代码,单独走一个维护好的外网白名单列表。这能挡住大多数恶意请求和依赖注入问题。
超时比任务超时多留30秒。这里有个细节,HTTP客户端超时如果跟沙箱任务超时设置成一样,容易出现“沙箱还没报错、客户端先超时断开”的尴尬局面,导致无法判断是任务失败还是网络抖动。多留30秒,可以拿到沙箱明确的返回结果。
命令跟代码分离。文件参数里放代码,command字段明确写执行命令。这样做的好处是,以后如果要支持R语言、Node.js脚本,只需要更换runtime和command,不用动整个客户端逻辑。我现在已经这样拓展了一种R脚本的分析任务,改动很小。
3.4 会话持久化与记忆管理
Agent会话和记忆的管理,是我觉得这套方案里最有价值的部分。我坚持一个原则:会话不等于历史消息列表。你如果直接把每一轮对话都无条件存下来,很快内存和检索都会崩溃。我的做法是三步:
第一步,每轮对话生成结构化记忆摘要。原始对话存到对象存储里做审计,同时把关键信息提取出来,生成一个“记忆对象”。一个记忆对象包含:用户意图、关键实体、决策结果、时间戳。然后用LLM把这一段总结成60字以内的浓缩句,做成向量存到WeKnora。
第二步,跨会话召回。每次新会话开始时,先用当前的问题去WeKnora召回历史记忆。这一步能实现“老用户问候”“上周讨论过的方案继续推进”这类连贯交互。如果用户ID相同,我会额外加一个过滤条件,只召回该用户的记忆,避免不同用户的信息混淆。
第三步,记忆归档与遗忘。长期不活跃的会话,每个月的最后一天触发一次归档任务,把近期低频记忆做二次浓缩,放冷存储。这个机制保证了记忆库不会无限膨胀,同时保留高价值信息。打个比方,这就像大脑的睡眠——把今天的重要记忆固化,把琐碎细节清空。
我在实现会话恢复时,直接读PostgreSQL里记录的状态,反序列化Agent运行时对象,重连到WeKnora的向量记忆。实际测试中,一个连续跑了三个小时的文档问答Agent,中途进程被kill掉,重启后能在15秒内恢复到中断前的对话状态,用户重新打开页面就能继续提问。这比从头重跑体验好了不是一点半点。
4. 常见问题与排查技巧实录
4.1 问题速查表
我把我在这套环境中线上碰到的典型问题整理成了速查表,按症状、原因、解决方案三列排布,方便对照排查。
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| Agent回答时引用错误文档 | WeKnora检索Top K配置过大,召回杂质 | 缩小Top K,调高Rerank阈值 |
| 任务一直pending不执行 | 调度器挂了,或Redis Stream消费者没启动 | 检查Worker进程,查看消费组状态 |
| CubeSandbox执行超时 | 脚本单任务跑太久,资源配额不足 | 调大timeout_s,或优化任务拆分 |
| 沙箱内import包失败 | 镜像缺少依赖,且网络隔离无法安装 | 在自定义镜像预装依赖包 |
| 记忆召回不到合适内容 | 向量切分粒度不合适,或记忆中缺乏实体信息 | 调整切片长度,增强结构化摘要 |
| 沙箱执行网络请求被拒 | 网络白名单未配置 | 按需开放白名单域名 |
| 状态恢复后Agent重复回复 | 事件流重复消费 | 启用幂等处理,按task_id去重 |
4.2 排查思路:从日志和事件流入手
我最依赖的调试手段不是看代码,而是看事件流。如果Agent行为异常,我会先打开PostgreSQL里的事件流表:
SELECT * FROM agent_events WHERE session_id = 'session_8891' ORDER BY seq ASC;事件流会精确显示Agent每一步做了什么:何时检索知识、检索到哪些片段、生成了什么动作、沙箱返回了什么结果。对比正常序列和异常序列,很快就能定位是哪一步偏离了预期。
举个真实案例。有一次文档问答Agent出现了“答非所问”的情况,表面上看起来像是模型抽风。我查事件流发现,Agent在检索知识前,做了一个“问题改写”的动作,把用户的原问题改成了一个八竿子打不着的宽泛问题。问题根源找到了,是改写提示词的约束不够强。加了一句“不得扩大或缩小问题的边界范围”之后,答非所问的频率立刻降了下来。
这里还分享一个排查技巧:给每个任务加trace_id。从任务入队开始,所有环节的日志都带上trace_id,用日志系统聚合查询。在分布式环境里,没有trace_id,你根本不知道Agent走的哪条链路。
4.3 避坑心得:五条用真金白银换的经验
最后分享几条我这几个月攒下来的避坑心得。
第一,别在Worker进程里写死任何Agent状态。我一开始图省事,把Agent实例直接放在Worker内存里,结果每次发布代码,连不上线的Agent状态全丢。后来强制一切状态进Redis/PostgreSQL,进程变成无状态Worker,发布再也不用担心状态丢失。
第二,LLM生成的代码要在沙箱里先跑一次冒烟测试。对于经常生成的某种模板代码,我维护了一个冒烟测试集合,新代码先跑一次轻量级测试,通过后再交给真正的任务执行。这个操作只多花几秒钟,却能把很多低级错误前置拦截掉。
第三,WeKnora的索引更新要设计成增量。刚开始图省事,每次文档更新都重建全量索引。文档多了之后,每次重建都要十几分钟,而且重建期间检索服务不可用。后来改成增量索引,新文档进来只更新对应切片,老索引热切换,基本不影响线上服务。
第四,对沙箱的容器镜像做版本管理。如果Agent要同时跑多个项目,每个项目可能有不同的依赖库版本。我一开始用一个大而全的镜像,结果不同项目的依赖互相冲突,整得焦头烂额。后来按项目类型拆分镜像,Python数据分析一个镜像、R脚本一个镜像、Node.js工具一个镜像,问题就消失了。
第五,监控里一定要加“长时间静默”这一项。Agent任务长时间没有任何事件汇报,很多时候不是正常,而是卡死了。我设置了一个规则:任务超过15分钟无事件输出就报警,人工介入查看。这条规则帮我发现了三个隐藏的沙箱死锁问题,都是因为agent进入了一个等待外部回调的陷阱状态。
我把这套环境在内部跑了两个多月,目前有十几个不同职责的Agent在线运行。回过头看,当初决定从框架派转向“自建运行时 + WeKnora + CubeSandbox”这条路,虽然前期多花了一些搭建时间,但换来的是状态不丢、执行安全、知识可控——这三条,恰恰是Agent从玩具走向生产力的关键。如果你也正在被Agent的稳定性、记忆和工具执行安全问题困扰,不妨试试这个思路,至少它能帮你把“给Agent做长期运行底座”这个问题拆解成可以一步步执行的具体任务。每次我把这段经验讲给团队里的新人,我都会补一句:Agent真正难的从来不是让它回答对一次,而是让它稳定、安全地回答对一万次。