MAS多 Agent 工作台

五个构建运行时时的取舍:隔离、流式、状态与失败

做 Agent 运行时,功能表很短,坑却很深。下面五个决定,每一个都改过至少一次,写下来是因为它们看起来都像“显然应该反过来做”。

一、隔离放在数据库,而不是应用层

最早的做法是每个查询都加 WHERE tenant_id = ?。它工作了三个月,然后在第 41 个入口漏了一次——不是谁不认真,是这件事本质上需要 41 次正确。

现在的做法是:所有业务表带 tenant_id,用 PostgreSQL 行级安全(RLS)在数据库层强制过滤,应用连接的会话变量决定“我是谁”。应用忘记加条件时,数据库返回零行,而不是别人的数据。

代价很直接:

  • 每条 SQL 多一次策略判断(微秒级,可忽略);
  • 迁移、后台脚本、运维 CLI 都必须显式设定租户上下文,否则它们自己也看不到数据;
  • 跨租户查询(平台管理员视角)要写“带审计的显式逃生门”,不能顺手 SELECT。

用 41 次“记得加条件”换 1 次“漏了就漏了”,这笔账在前三个月看不出来,在第四个月非常明显。

二、流式用 SSE,不用 WebSocket

Agent 的输出是单向、长、可中断的:一次回复可能持续几分钟,中间夹杂工具调用事件。

WebSocket 更灵活,但我们不需要双向:客户端发消息是普通 POST,回复是服务端推的事件流。用 SSE 意味着:

  • 走标准 HTTP,反向代理、鉴权中间件、日志、超时都是现成的;
  • 断线重连可以用浏览器原生 EventSource 的语义;
  • 不需要自己维护连接状态机与心跳。

代价是不能从服务端“推送任意东西”——比如“别的会话完成了”。真要这类通知,加一个轻量轮询或第二个通道,比让整个系统活在一条双向 socket 上便宜得多。

三、沙盒用完即弃,但会话不是

沙盒的默认生命周期是“随任务”,不是“随账号”。任务结束,容器回收。

这条决定带来一个不舒服的后果:用户会问“我上次装的那个包呢”。答案是:没了,但它在会话的文件快照里。于是我们需要区分三种东西:

东西 生命周期 存在哪
进程、临时目录 任务 沙盒实例
文件、产物 会话 会话文件存储
技能、提示词、配置 租户 数据库

边界清楚之后,“为什么我的环境变了”这类问题少了一大半——因为环境本来就不该被依赖,能被依赖的是文件。

四、失败要进上下文,不要只进日志

模型改代码时最常见的失败是:命令退出了非零,工具返回一句“执行失败”,模型不知道错在哪,于是它开始猜。

正确做法是把 stderr、退出码、被截断的原始输出一起放进工具结果,并明确告诉模型“这是截断后的前 N 行,完整日志在某个路径”。模型不需要被保护,它需要被喂真实数据。

这条也适用于我们自己的前端:错误提示里加“复制诊断信息”,比加一句“请稍后重试”有用。

五、慢一点,但要可解释

有些选择纯粹为了可解释性,代价是性能:

  • 不做隐式重试。重试会在账本里留下两条记录,用户看到的是“为什么扣了两次”。
  • 不自动切换模型。主模型挂了就报错,而不是偷偷换一个更便宜的,让输出风格突然变化。
  • 工具调用全部留痕。一次任务可能有 40 条事件,全都可查。存储不便宜,但“AI 到底做了什么”这个问题必须有答案。

这些取舍的共同点

它们都在回答同一个问题:出错的时候,系统能不能说清楚发生了什么。

Agent 系统最容易骗人的地方,是它总能在失败时产出一段听起来合理的解释。运行时的责任,是让“实际发生了什么”先于“听起来像什么”存在——真实的退出码、真实的隔离边界、真实的计费记录。

这也是为什么我们把多租户、审计、配额放在功能列表前面:不是它们更酷,是它们决定了其他人敢不敢把真实工作交进来。