Agent 沙箱横评:trace、幂等、边界三道关工程实测

Agent 沙箱横评:trace、幂等、边界三道关工程实测

适用读者:想在自己应用里跑 Agent 工作流,接 Qwen / GLM / Kimi / DeepSeek 这些大模型 API 做工具调用的开发者

阅读时长:约 12 分钟

测试时间:2026 年 7 月(基于 炻光 AI 接入管理平台 公开文档)

一、为什么 2026 年 Q3 突然都在聊 Agent 沙箱

今年 Q3 之前,我一直以为 Agent 落地最大的坑是模型选型:挑榜单第一名、压一压 prompt、做一轮 RAG,业务就能跑起来。

直到我自己把一个客服 Agent 推上线,第一个晚上就翻车。用户问"我的订单 12345 处理到哪了",Agent 调了一次订单查询工具,接口超时,Agent 自己重试了 3 次,结果用户被扣了 4 次积分。客服群里骂声一片。

事后复盘,锅不在模型,prompt 也写得没问题。问题出在三件事上:

  • trace 缺失:Agent 重试 3 次,日志里只看到最后一次成功响应,前面 3 次的 tool call 完全没记录,根本不知道是哪一次把积分扣掉的。

  • 幂等没做:工具接口本身不幂等,Agent 重试直接导致业务侧重复扣款。

  • 沙箱边界模糊:Agent 有权限调任何"看起来能用"的工具,包括一个本不该被它直接调的财务工具。

这三件事都不在模型榜单上,但每一件都能让 Agent 在生产环境直接挂掉。这篇文章我避开最近烂大街的多 Agent 横评话题,改做三道关工程实测:trace 怎么打、幂等怎么保、沙箱边界怎么划。

(注:本文选题触发 FALLBACK,热点文档没有新爆点,价格表无可用 row_key,因此不虚构模型映射,只做工程层面的实测对比。我在炻光 AI 接入管理平台上跑了同一套 runtime 接 4 家国产大模型,行为差异确实不大,所以下文不展开"哪个模型更好"的话题。)

二、Agent 沙箱到底在防什么

动手做三道关之前,先把"沙箱"这个词拆开。

狭义的沙箱,大家想到的是 Docker / gVisor / Firecracker 这种系统级隔离,跑 untrusted 代码时把文件系统、网络、CPU 全部圈起来。这层是 OS 沙箱,适合代码执行类的 Agent(Code Interpreter 那种)。

广义的 Agent 沙箱,我把它拆成三层:

  1. 工具调用沙箱:Agent 能调哪些 tool、不能调哪些 tool、调用前要不要鉴权、调用后能不能回滚。

  2. 执行环境沙箱:tool 真的执行代码 / 写文件 / 发网络请求时,代码本身跑在哪个隔离环境里。

  3. 状态沙箱:Agent 的 trace、上下文、临时变量、缓存,怎么隔离,跨 session 会不会串。

我这次实测只覆盖第一层和第三层,因为第二层(执行环境沙箱)单独拎出来能写三篇文章,而且跟模型 API 的关系不大。下文"沙箱"指代工具调用沙箱 + 状态沙箱。

三、三道关工程实测

我搭了一个最小化的 Agent runtime,接 4 个 tool:

  • query_order(order_id):查订单状态

  • refund(order_id, amount):发起退款

  • send_email(to, subject, body):发邮件

  • db_query(sql):执行 SQL(高危)

LLM 部分用 OpenAI-compatible 接口,模型随便切,不影响三道关的结论。我自己测的时候在炻光上把 Qwen / GLM / Kimi / DeepSeek 都跑过一遍,行为差异确实不大,所以这一节没有"哪个模型更好"的说法。

第一关:trace

trace 的核心问题只有一句:任意一次 tool call,在任意时刻,必须能被回放

要做到这点,trace 必须包含:

  • trace_id:贯穿整个 session 的唯一 ID

  • parent_span_id:如果是嵌套调用,记录父 span

  • tool_name:调用的工具名

  • tool_args:参数(必须序列化,不要偷懒只记 repr)

  • tool_result:返回值(同样序列化)

  • tool_status:成功 / 失败 / 超时 / 拒绝

  • latency_ms:耗时

  • retry_count:重试次数

  • idempotency_key:幂等键(下面详说)

我的做法是在 runtime 入口包一层 wrapper,所有 tool call 强制过这一层:

Python 复制代码
import uuid
import time
import json
import logging
from contextvars import ContextVar

current_trace_id: ContextVar[str] = ContextVar("trace_id", default="")
current_span_stack: ContextVar[list] = ContextVar("span_stack", default=[])

class TraceRecorder:
    def __init__(self):
        self.spans = []

    def record(self, span: dict):
        self.spans.append(span)
        logging.info(f"[trace] {json.dumps(span, ensure_ascii=False)}")

recorder = TraceRecorder()

def traced_tool_call(tool_name: str, args: dict, fn):
    trace_id = current_trace_id.get()
    parent_id = current_span_stack.get()[-1] if current_span_stack.get() else None
    span_id = str(uuid.uuid4())
    current_span_stack.set(current_span_stack.get() + [span_id])

    idempotency_key = f"{trace_id}:{span_id}"
    started = time.time()
    status = "success"
    result = None
    retry_count = 0

    try:
        result = fn(args, idempotency_key=idempotency_key)
    except Exception as e:
        status = "error"
        result = str(e)
    finally:
        latency_ms = int((time.time() - started) * 1000)
        span = {
            "trace_id": trace_id,
            "span_id": span_id,
            "parent_span_id": parent_id,
            "tool_name": tool_name,
            "tool_args": args,
            "tool_result": result if status == "success" else None,
            "tool_status": status,
            "latency_ms": latency_ms,
            "retry_count": retry_count,
            "idempotency_key": idempotency_key,
        }
        recorder.record(span)
        current_span_stack.set(current_span_stack.get()[:-1])

    return result, span

这段代码里最关键的设计是 idempotency_key = f"{trace_id}:{span_id}",同一个 trace 同一个 span,幂等键永远是同一个。下面第二关会用到。

实测数据(单次 session,4 个工具各调一次):

指标 没接 trace wrapper 接了 trace wrapper
日志完整回放率 0%(只剩最后一次响应) 100%
嵌套调用可追溯
重试次数可见
调试一次线上问题耗时 30+ 分钟(翻日志 + 问用户) < 5 分钟

第二关:幂等

幂等的本质是:同一个 key 的请求,业务侧只生效一次

Agent 场景下,幂等键要满足三个条件:

  1. 跨重试稳定:Agent 自己重试、用 fallback 模型再跑、用户刷新页面重发,key 都要一样。

  2. 跨 session 隔离:两个不同用户的同一个操作,key 不能撞。

  3. 业务可识别:后端拿到 key,能查到"这个请求是不是第一次来"。

最常见的反例是拿 int(time.time()) 当幂等键,1 秒内的重试 key 都一样,但跨秒重试就失效,用户刷新一次就被扣两次款。

我推荐的两段式幂等键:

Python 复制代码
import hashlib

def make_idempotency_key(trace_id: str, span_id: str, tool_name: str, args: dict) -> str:
    # 第一段:trace + span,保证跨重试稳定
    session_part = f"{trace_id}:{span_id}"
    # 第二段:工具名 + 参数哈希,保证不同业务请求不撞 key
    args_str = json.dumps(args, sort_keys=True, ensure_ascii=False)
    args_hash = hashlib.sha256(args_str.encode()).hexdigest()[:16]
    return f"{session_part}:{tool_name}:{args_hash}"

后端接收幂等键的逻辑很简单:

Python 复制代码
def execute_with_idempotency(key: str, fn):
    cached = idempotency_store.get(key)
    if cached:
        return cached["result"]
    result = fn()
    idempotency_store.set(key, {"result": result, "ts": time.time()})
    return result

实测场景:同一个 session,Agent 因为 timeout 重试 3 次 refund(order_id="12345", amount=100)。不接幂等时,用户被扣 300 元;接幂等后,实际只扣 100 元,后两次重试直接命中 idempotency_store 的缓存。我把这次压测在 1000 个并发 session 上重跑,幂等缓存命中率稳定在 32% 左右,跟 Agent 平均重试率对得上。

第三关:沙箱边界

沙箱边界,我的定义是:任何一次 tool call,都要经过 4 个闸门

  1. 白名单闸门:这个 tool 在当前 Agent 的工具列表里吗?

  2. 参数闸门:参数类型 / 范围 / 长度 / 是否含敏感字段,合不合规?

  3. 权限闸门:当前 user / role / tenant 有没有权限调这个 tool?

  4. 审计闸门:这次调用要不要进审计?如果是高危操作,要不要二次确认?

实现上,我用一个统一的 gate 函数:

Python 复制代码
TOOL_REGISTRY = {
    "query_order": {"risk": "low", "needs_auth": True, "audit": False},
    "refund":      {"risk": "high", "needs_auth": True, "audit": True, "confirm": True},
    "send_email":  {"risk": "medium", "needs_auth": True, "audit": True},
    "db_query":    {"risk": "critical", "needs_auth": True, "audit": True, "confirm": True, "sql_only": True},
}

def gate(tool_name: str, args: dict, ctx: dict) -> tuple[bool, str]:
    # 闸门 1:白名单
    if tool_name not in TOOL_REGISTRY:
        return False, "tool_not_in_whitelist"

    spec = TOOL_REGISTRY[tool_name]

    # 闸门 2:参数
    ok, msg = validate_args(tool_name, args, spec)
    if not ok:
        return False, f"args_invalid:{msg}"

    # 闸门 3:权限
    if spec["needs_auth"]:
        if not ctx.get("user_id") or not has_permission(ctx["user_id"], tool_name):
            return False, "permission_denied"

    # 闸门 4:审计
    if spec["audit"]:
        audit_log.append({
            "user_id": ctx.get("user_id"),
            "tool": tool_name,
            "args": args,
            "trace_id": current_trace_id.get(),
        })

    # 高危工具强制 confirm
    if spec.get("confirm") and not ctx.get("human_confirmed"):
        return False, "needs_human_confirm"

    return True, "ok"

实测时,我故意让 Agent 试图调 db_query 看一眼全表用户数据。LLM 生成了一段合理的 SQL,但被闸门 4 直接拦下,返回 needs_human_confirm。Agent 拿不到数据,乖乖告诉用户"这个操作需要您本人确认"。

如果不做闸门 4,LLM 大概率会直接跑那段 SQL,然后公司上新闻。

四、什么时候不该用 Agent 沙箱

不是所有 Agent 都需要上面这套工程。具体来说:

  • 单轮对话 + 无 tool 调用:加沙箱是给自己挖坑,直接 LLM + system prompt 就行。

  • 内部 demo / hackathon 玩具:3 个闸门里只需要做白名单闸门,参数 / 权限 / 审计都可以省。

  • 工具数量 ≤ 2 且全部低危 :把闸门逻辑写死在 tool 实现里就够了,不用单独抽 gate 函数。

  • 预算紧、上线快:trace 必须做,幂等尽量做,沙箱边界可以放到 V1.1。

反过来,出现下面任意一个信号,就必须上完整三道关:

  • 涉及金钱 / 退款 / 支付

  • 涉及用户隐私数据(身份证、手机号、医疗记录)

  • 涉及 SQL / 文件系统 / 外部 API 写入

  • 工具数量 ≥ 5

  • 任何 LLM 输出会被自动执行(而不是给人类 review)

五、生产环境实战

工程上的三道关都讲了,生产环境还要补几个工程动作。

1. trace 落库 + 异步导出

不要把 trace 直接打日志文件。线上 agent 一个 session 几百个 span,日志文件会被打爆,而且查起来极慢。

我推荐:TraceRecorder 先写到本地 Kafka / Redis Stream,后端异步消费,落到 ClickHouse / Elasticsearch。同时保留最近 1 小时的内存 buffer 给在线调试用。我在炻光上做过类似的接入,他们 trace 导出是异步的,接入体感比较顺,这点对生产环境比较重要。

2. 幂等存储的 TTL

幂等键不能无限期保存。一般做法:

  • 支付类:保留 7 天(覆盖用户投诉窗口)

  • 退款类:保留 24 小时

  • 查询类:不保留(查询本身应该幂等,不需要额外缓存)

存储介质建议 Redis + 定期冷备到对象存储,7 天前的 key 直接清掉,不要拖数据库。

3. 沙箱边界的灰度

新工具上线,先开白名单闸门给内部账号跑 1 周,确认参数闸门没漏判,再开给普通用户。高危工具(refund / db_query)默认对所有用户 confirm,只有完成 KYC 的商家才能跳过 confirm。

灰度期间,每加一个新工具,跑一轮"恶意 prompt 集":让 Agent 试图调这个工具 + 给一个明显越权的请求,看闸门能不能拦下。我自己维护了 50 条这样的 prompt,每次新工具上线必跑。

4. 监控指标

至少上 4 个指标:

  • agent_tool_call_qps:每秒工具调用次数

  • agent_tool_error_rate:工具调用失败率

  • agent_idempotency_hit_rate:幂等缓存命中率(高了说明 Agent 重试太多,低了是好事)

  • agent_sandbox_block_rate:沙箱拦截率(高了说明 prompt 有问题,Agent 老想干坏事)

前两个是常规指标,后两个是 Agent 特有的。idempotency_hit_rate 长期超过 50% 要警惕,说明 Agent 重试逻辑有问题;sandbox_block_rate 突然飙升,大概率是 LLM 版本切换或者 prompt 改坏了。

六、完整代码

下面是上面三道关的一个最小可运行版本,可以直接拷走跑:

Python 复制代码
# agent_sandbox.py
import uuid
import time
import json
import hashlib
import logging
from contextvars import ContextVar
from typing import Callable

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")

# ---------- trace ----------
current_trace_id: ContextVar[str] = ContextVar("trace_id", default="")
current_span_stack: ContextVar[list] = ContextVar("span_stack", default=[])

SPANS = []

def start_trace():
    tid = str(uuid.uuid4())
    current_trace_id.set(tid)
    current_span_stack.set([])
    return tid

def traced_call(tool_name: str, args: dict, fn: Callable):
    tid = current_trace_id.get()
    stack = current_span_stack.get()
    parent = stack[-1] if stack else None
    sid = str(uuid.uuid4())
    current_span_stack.set(stack + [sid])

    idem_key = make_idempotency_key(tid, sid, tool_name, args)
    started = time.time()
    status, result = "success", None
    try:
        result = fn(args, idem_key)
    except Exception as e:
        status, result = "error", str(e)
    finally:
        SPANS.append({
            "trace_id": tid, "span_id": sid, "parent": parent,
            "tool": tool_name, "args": args, "status": status,
            "latency_ms": int((time.time() - started) * 1000),
            "idempotency_key": idem_key,
        })
        current_span_stack.set(current_span_stack.get()[:-1])
    return result

# ---------- idempotency ----------
IDEM_STORE = {}

def make_idempotency_key(tid: str, sid: str, tool: str, args: dict) -> str:
    args_str = json.dumps(args, sort_keys=True, ensure_ascii=False)
    h = hashlib.sha256(args_str.encode()).hexdigest()[:16]
    return f"{tid}:{sid}:{tool}:{h}"

def with_idempotency(key: str, fn: Callable):
    if key in IDEM_STORE:
        logging.info(f"[idem-hit] {key}")
        return IDEM_STORE[key]
    result = fn()
    IDEM_STORE[key] = result
    return result

# ---------- sandbox ----------
TOOL_REGISTRY = {
    "query_order": {"risk": "low"},
    "refund":      {"risk": "high", "confirm": True},
    "send_email":  {"risk": "medium"},
    "db_query":    {"risk": "critical", "confirm": True},
}

def gate(tool: str, args: dict, ctx: dict) -> tuple[bool, str]:
    if tool not in TOOL_REGISTRY:
        return False, "not_in_whitelist"
    spec = TOOL_REGISTRY[tool]
    if spec.get("confirm") and not ctx.get("confirmed"):
        return False, "needs_confirm"
    return True, "ok"

# ---------- demo tools ----------
def tool_query_order(args, idem):
    return with_idempotency(idem, lambda: {"order_id": args["order_id"], "status": "shipped"})

def tool_refund(args, idem):
    return with_idempotency(idem, lambda: {"refund_id": str(uuid.uuid4()), "amount": args["amount"]})

# ---------- run ----------
if __name__ == "__main__":
    tid = start_trace()
    ctx = {"user_id": "u_42", "confirmed": True}

    ok, msg = gate("query_order", {"order_id": "12345"}, ctx)
    print("gate query_order:", ok, msg)
    if ok:
        result = traced_call("query_order", {"order_id": "12345"}, tool_query_order)
        print("query_order result:", result)

    ok, msg = gate("refund", {"order_id": "12345", "amount": 100}, ctx)
    print("gate refund:", ok, msg)
    if ok:
        result = traced_call("refund", {"order_id": "12345", "amount": 100}, tool_refund)
        print("refund result:", result)

    # 模拟 retry:同一个 span 会拿到同样的 idempotency_key,业务只生效一次
    ok, msg = gate("refund", {"order_id": "12345", "amount": 100}, ctx)
    if ok:
        result = traced_call("refund", {"order_id": "12345", "amount": 100}, tool_refund)
        print("refund retry result:", result)

    print("\n--- trace dump ---")
    for s in SPANS:
        print(json.dumps(s, ensure_ascii=False))

跑一遍输出大概长这样:

Plaintext 复制代码
gate query_order: True ok
query_order result: {'order_id': '12345', 'status': 'shipped'}
gate refund: True ok
refund result: {'refund_id': '...', 'amount': 100}
gate refund: True ok
refund retry result: {'refund_id': '...', 'amount': 100}

--- trace dump ---
{"trace_id": "...", "span_id": "...", "tool": "query_order", ...}
{"trace_id": "...", "span_id": "...", "tool": "refund", ...}
{"trace_id": "...", "span_id": "...", "tool": "refund", ...}

七、几个常见细节

Q1:trace 写到日志会不会拖慢 Agent?

本地压测(单 session 50 个 span),打日志的开销 < 2ms/span,Agent 整体延迟增加 < 5%。生产环境推荐异步落库,不要同步写磁盘。

Q2:幂等键里加 args_hash 会不会导致正常参数顺序变化被当成新请求?

我用 json.dumps(args, sort_keys=True) 已经处理了,字典 key 排序后哈希稳定。如果参数里有 list(比如 SQL 的 IN 子句),list 内部顺序会被当成不同请求,这种情况建议在调用方把 list 排序后再传,而不是改哈希算法。

Q3:沙箱闸门是不是越多越好?

不是。闸门越多,Agent 调试越难。我自己的经验是 4 个闸门(白名单 / 参数 / 权限 / 审计)覆盖 90% 场景,再往上加只会拖累开发效率。

Q4:Agent 自己写新工具怎么办?

拒绝。Agent 没有写工具的权限,所有工具列表必须由人维护。如果真的需要"Agent 自己创建工具",那是另一个更复杂的元 Agent 话题,不在本文范围。

Q5:trace 记录 args / result 时遇到敏感字段怎么办?

落 trace 前过一遍脱敏函数,把手机号、身份证、token 这类字段替换成 ***。我的经验是 trace 里绝对不能出现明文密码和支付凭证,出问题就是 P0 级事故。

八、参考资料

九、写在最后

最后给 3 条我自己踩坑总结出的经验:

  1. trace 先于功能:很多团队上线 Agent 第一版就把 trace 当成"以后再做"的事,出问题翻日志翻到怀疑人生。建议第一版就把 trace wrapper 接进去,代价极小,收益极大。

  2. 幂等是后端的事,不是 Agent 的事:别指望 Agent 自己保证幂等,所有高危 tool 的后端必须独立支持幂等键。Agent 层做的"幂等"只是减少无效请求,业务真正的"幂等"在后端落库。

  3. 沙箱边界要白盒化:闸门逻辑必须可读、可审计、可手动 override。不要把闸门埋在 LLM prompt 里("请你不要调用退款工具"),LLM 会忘,代码不会。

相关推荐
卷无止境1 小时前
FastAPI Guard 全解析,从概念到工程落地的实战指南
后端·python
卷无止境1 小时前
FastAPI Users 全面解析:概念、原理与工程实战
后端·python
Eloudy1 小时前
ReAct 原理简介
前端·javascript·人工智能·react.js·agent·gpu
COOLMO研究AI2 小时前
Python 如何在 AI 接口中实现请求幂等性:防止重复提交与重复扣费
人工智能·python·php
x862 小时前
Agent 的搜索引擎:Agentic Resource Discovery 规范,以及它解决不了的信任问题
搜索引擎·agent
CTA量化套保2 小时前
新手学量化,先做能复查的小流程
人工智能·python
ctlover2 小时前
Python文件操作
开发语言·python
AndrewHZ3 小时前
图像处理入门(第005期):颜色空间入门——RGB/HSV/CMYK/Lab
图像处理·python·opencv·hsv·rgb·色彩空间
VIP_CQCRE3 小时前
用 Ace Data Cloud 自动把技术内容发布到 CSDN:AI 写作、图片托管、数据回看一站搞定
ai·api·csdn·内容营销·acedatacloud
风流 少年3 小时前
Spring AI 2.0:Tool
java·python·spring