五个构建运行时时的取舍:隔离、流式、状态与失败
做 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 系统最容易骗人的地方,是它总能在失败时产出一段听起来合理的解释。运行时的责任,是让“实际发生了什么”先于“听起来像什么”存在——真实的退出码、真实的隔离边界、真实的计费记录。
这也是为什么我们把多租户、审计、配额放在功能列表前面:不是它们更酷,是它们决定了其他人敢不敢把真实工作交进来。