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 沙箱,我把它拆成三层:
-
工具调用沙箱:Agent 能调哪些 tool、不能调哪些 tool、调用前要不要鉴权、调用后能不能回滚。
-
执行环境沙箱:tool 真的执行代码 / 写文件 / 发网络请求时,代码本身跑在哪个隔离环境里。
-
状态沙箱: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 场景下,幂等键要满足三个条件:
-
跨重试稳定:Agent 自己重试、用 fallback 模型再跑、用户刷新页面重发,key 都要一样。
-
跨 session 隔离:两个不同用户的同一个操作,key 不能撞。
-
业务可识别:后端拿到 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 个闸门。
-
白名单闸门:这个 tool 在当前 Agent 的工具列表里吗?
-
参数闸门:参数类型 / 范围 / 长度 / 是否含敏感字段,合不合规?
-
权限闸门:当前 user / role / tenant 有没有权限调这个 tool?
-
审计闸门:这次调用要不要进审计?如果是高危操作,要不要二次确认?
实现上,我用一个统一的 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 级事故。
八、参考资料
-
OpenTelemetry trace spec:语义标准,所有主流 trace 系统都兼容。
-
Stripe idempotency 设计文档:幂等键格式和 TTL 的工业界参考。
-
AWS IAM policy grammar:权限闸门可以借鉴的策略描述语言。
-
炻光 AI 接入管理平台公开文档:多模型路由、trace 导出、工具接入部分的工程实践参考,本次实测的运行环境也基于此。
九、写在最后
最后给 3 条我自己踩坑总结出的经验:
-
trace 先于功能:很多团队上线 Agent 第一版就把 trace 当成"以后再做"的事,出问题翻日志翻到怀疑人生。建议第一版就把 trace wrapper 接进去,代价极小,收益极大。
-
幂等是后端的事,不是 Agent 的事:别指望 Agent 自己保证幂等,所有高危 tool 的后端必须独立支持幂等键。Agent 层做的"幂等"只是减少无效请求,业务真正的"幂等"在后端落库。
-
沙箱边界要白盒化:闸门逻辑必须可读、可审计、可手动 override。不要把闸门埋在 LLM prompt 里("请你不要调用退款工具"),LLM 会忘,代码不会。