Skip to content
个人学习网站
5 分钟阅读

把 AI Agent 塞进一个进程里,第一天就会撞上两堵墙

Agent 沙盒系列第一篇。让 AI 帮你连续跑两小时的活,为什么"能跑通"和"能上线"之间差着一道隔离?用一个会被系统强制杀掉的小服务做基线,把问题看清楚。术语都有解释,不熟悉 Kubernetes 也能读。

设想一个很常见的需求:让 AI 帮你把三十个网站的数据抓下来、清洗、整理成一份报告。这个活不像聊天,问一句答一句就结束——它要连续跑两个小时,中间会下载文件、占内存、还会执行 AI 自己临时写出来的代码。

在自己电脑上跑通它,一个下午就够了。把它变成一个多人同时用的服务,难的地方才开始。

这是「从零构建 Agent 沙盒」系列的第一篇,一共六篇。每篇只解决上一篇暴露出来的一个问题,代码都在 agent-sandbox-oss 里,可以自己跑一遍。文章里的术语第一次出现时都会解释,不需要你熟悉 Kubernetes。

先说清楚这些代码是什么:教学骨架。真实的抓取被替换成了“睡一会儿”,集群跑在笔记本上。它们不是拿来上生产的,价值在于——每一步翻的车,都是真实项目里会翻的车。

最自然的写法,和它藏着的假设

要做这么一个服务,最自然的写法是:一个 Web 接口收任务,收到就在后台开始干,任务进度记在内存里。

IN_MEMORY_DB = {}   # 任务进度就存在这里

async def run_data_research_task(task_id, target_pages, simulate_leak=False):
    for i in range(target_pages):
        await asyncio.sleep(0.5)                      # 假装在抓一个网页
        IN_MEMORY_DB[task_id]["pages_crawled"] += 1   # 记一笔:又抓完一页

部署也是最自然的:一份程序、一个容器,给它 128MB 内存的上限。

容器可以理解成一台一次性的小服务器:程序和它需要的环境打成一个包,随时能启动、能销毁。内存上限是给它划的红线——超过就会被系统强制关掉。

这个写法没有任何“错误”。它能跑、能返回任务号、能查进度。问题在于它默默做了一个假设:所有任务都乖

真实世界里不成立。三十个网站里总有一个页面特别大,AI 生成的代码也总有一次会写出死循环。下面这两件事,就是这个假设破掉时发生的。

第一堵墙:一个任务出事,所有人陪葬

代码里留了个开关,模拟“抓到一个巨大的网页”——每抓一页就往内存里塞 20MB。第七页的时候,内存越过 128MB 那条红线,系统直接把整个程序关掉了。

在 Kubernetes(一套管容器的系统,下文简称 K8s)里,这个过程你能直接看到:

kubectl get pod -l app=agent -w
# NAME                 READY   STATUS      RESTARTS
# agent-monolith-xxx   1/1     Running     0
# agent-monolith-xxx   0/1     OOMKilled   0      ← 内存超标,被强制杀掉
# agent-monolith-xxx   1/1     Running     1      ← 几秒后自动重启,看起来没事了

OOMKilled 就是“内存用超了,被干掉了”。看到它自动重启,第一反应通常是松口气:系统自愈了嘛。

但这时候去查另一个同时在跑的任务——那个一直老老实实抓小网页的任务:

curl localhost:8000/status/<另一个任务的 id>
# {"detail":"Task not found. Data lost due to Pod crash!"}
# 任务找不到了:进程崩溃,数据已丢失

它也没了。跑了一小时四十分钟的进度,一起归零。

因为被杀掉的不是“那个出问题的任务”,而是装着所有任务的那个程序。 打个比方:十个人合租一间房共用一个厨房,其中一个人把锅烧糊了触发消防喷淋——喷的是整间屋子,所有人的饭一起报废。

还有更难受的一点:因为进度只记在内存里,重启之后连“刚才跑到第几页了”都查不出来。K8s 的自动重启保护的是服务能不能被访问,不是任务做到哪了。它把门重新打开了,没把你的两小时找回来。

我把这叫作单体幻觉:RESTARTS: 1 这个数字看着人畜无害,代价却是当时所有在跑的任务。

第二堵墙:Agent 执行的是 AI 现场写的代码

这是 Agent 和普通后端服务最不一样的地方。普通服务执行的是你自己写好、审过、测过的代码;Agent 会执行大模型几秒钟前刚生成的代码。

而这个程序能碰到什么,容器里一清二楚:

cat /var/run/secrets/kubernetes.io/serviceaccount/token

这条命令读的是系统挂给这个程序的访问凭证——相当于一把钥匙。同一个程序里,意味着共享同一套文件、同一批环境变量、同一把钥匙。于是任务 A 生成的代码,能读到任务 B 抓下来的数据,也能读到这把钥匙。

这一条不是“写代码时小心一点”能解决的。只要在同一个程序里,边界就不存在——因为操作系统压根没提供“同一个进程内部还能互相防着”这种东西。

这一步定下来的事

这个最朴素的版本值得亲手跑一次,因为它一次证伪了两个很自然的想法:

  • “内存不够就多给点”——给到 1GB,只是把翻车推迟到第几十页,连坐关系一点没变;
  • “加个错误捕获不就行了”——内存超标是操作系统内核动的手,程序连喊一声的机会都没有。

所以路只剩一条:每个任务给一个自己的容器。听上去理所当然。

代价在下一篇:按需去创建一个容器,用户要在那儿干等 45 到 90 秒。

一句话版本:同一个进程里没有边界。隔离不是性能优化,是这类服务能不能对外提供的前提。

代码:agent-sandbox-oss/lab1,跑起来只要一个本地集群加一条命令。