Agent 的工具参数若无法指向先前观察到的来源即为猜测,应在写入文件或发送请求前失败closed,避免silent wrong answer。
大多数 Agent 回归问题并非源于缺失指令,而是工具参数——这些参数从未出现在任何先前的观测结果中。如果一个 span 无法指向某条源记录,那这通调用就是猜的,而猜出来的东西在写入文件、创建工单或再浪费一次免费层调用之前,就应该.fail closed。
日志仍然有用。Diff 仍然有用。两者都无法回答溯源问题。溯源是一个比"模型为何会跑偏"更便宜的问题,而且大多数 Agent 追踪压根不记录这个。
想象一个编译器,它在输出漂亮的构建日志的同时,却接受未声明的变量。这就是一个 Agent 发出了一条自信的工具调用,但其 path、issue_id 或 sha 从未被 list_dir、git_status 或 search 返回过。运行看起来是完整的。追踪却是空洞的。
本文提出了一个可复用的调试循环:每个工具参数在调用时刻被标记为 observed 或 assumed,span 携带这些标签运行,并且当 assumed 字段超出预算时整个运行失败。检查器是本地的。它不需要新的模型系列,也不需要你相信某个仪表盘。
当一个参数的值与先前工具结果中存储的值字节相等时,或者它是该值的严格、有文档说明的投影时(比如从目录列表中取出的文件名、从 rev-parse 中取出的 commit hash),该参数是 observed 的。当模型凭空发明了它、从系统提示词中拼凑出它、或者复用了上一次从未写入账本的失败调用的值时,该参数是 assumed 的。
这条规则故意写得很乏味。乏味的规则在截断的运行中存活。免费端点会在中途断流;重试包装器会打乱 stdout 顺序;父 span 会在因果关系上撒谎。一份具体值的账本不关心哪一行先打印。它只关心这些字节是否存在。
不要给提示词打标签。要给参数打标签。提示词混合了策略、示例和碎屑。参数才是写入面。如果写入面无法引用来源,其余的追踪就只是文学创作。
下面的产物是一个 proposed runner,不是生产级 SDK。它将工具输出记录为字段名到值的映射,对下一次调用的参数进行分类,并为每次调用发出一条 JSON span。将它复制到名为 assumption_trace.py 的文件中。
from __future__ import annotations
import hashlib
import json
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Literal
Kind = Literal["observed", "assumed"]
@dataclass
class Source:
tool: str
field: str
digest: str
@dataclass
class Ledger:
values: dict[str, Source] = field(default_factory=dict)
def remember(self, tool: str, payload: Any, prefix: str = "") -> None:
if isinstance(payload, dict):
for key, val in payload.items():
self.remember(tool, val, f"{prefix}{key}." if prefix else f"{key}.")
return
if isinstance(payload, list):
for i, val in enumerate(payload):
self.remember(tool, val, f"{prefix}{i}.")
return
if payload is None or payload == "":
return
text = str(payload)
digest = hashlib.sha256(text.encode()).hexdigest()[:16]
self.values[text] = Source(tool=tool, field=prefix.rstrip("."), digest=digest)
def classify(self, arguments: dict[str, Any]) -> dict[str, dict[str, Any]]:
tagged = {}
for name, raw in arguments.items():
text = "" if raw is None else str(raw)
src = self.values.get(text)
if src is None:
tagged[name] = {"kind": "assumed", "value_preview": text[:80]}
else:
tagged[name] = {
"kind": "observed",
"source_tool": src.tool,
"source_field": src.field,
"digest": src.digest,
}
return tagged
@dataclass
class AssumptionBudget:
max_assumed_fields: int = 0
allow: frozenset[str] = frozenset({"reason", "comment"})
def violations(self, tagged: dict[str, dict[str, Any]]) -> list[str]:
bad = []
assumed = [
name
for name, meta in tagged.items()
if meta["kind"] == "assumed" and name not in self.allow
]
if len(assumed) > self.max_assumed_fields:
bad.append(f"assumed_fields={assumed}")
return bad
class TracedTools:
def __init__(self, impl: dict[str, Callable[..., Any]], budget: AssumptionBudget):
self.impl = impl
self.budget = budget
self.ledger = Ledger()
self.spans: list[dict[str, Any]] = []
def call(self, tool: str, **arguments: Any) -> Any:
tagged = self.ledger.classify(arguments)
started = time.time()
violations = self.budget.violations(tagged)
span = {
"name": f"tool.{tool}",
"ts": started,
"arguments": tagged,
"violations": violations,
"status": "blocked" if violations else "ok",
}
if violations:
span["ended_ts"] = time.time()
self.spans.append(span)
raise PermissionError(f"{tool} blocked: {violations}")
result = self.impl[tool](**arguments)
self.ledger.remember(tool, result)
span["ended_ts"] = time.time()
span["result_type"] = type(result).__name__
self.spans.append(span)
return result
def dump(self, path: str) -> None:
with open(path, "w", encoding="utf-8") as handle:
json.dump({"spans": self.spans}, handle, indent=2)
重要的字段不是延迟。延迟只能告诉你免费端点慢了。source_tool 才能告诉你 repo_path 是被 pwd 返回的还是在一次截断思考之后被臆想出来的。当后续的 diff 看起来令人费解时,先打开 span。如果 kind 是 assumed,就停止阅读提示词。提示词在缺失观测的下游。
接入两个假工具。一个列出文件。一个"打补丁"到文件。第二个调用应该在模型提供了一个列表从未产生过的路径时终止。
from assumption_trace import AssumptionBudget, TracedTools
def list_files(root: str) -> dict:
return {"root": root, "files": ["app.py", "test_app.py"]}
def patch_file(path: str, comment: str) -> dict:
return {"patched": path, "comment": comment}
def run_good() -> None:
tools = TracedTools(
{"list_files": list_files, "patch_file": patch_file},
AssumptionBudget(max_assumed_fields=0),
)
listing = tools.call("list_files", root=".")
tools.call("patch_file", path=listing["files"][0], comment="keep tests green")
tools.dump("/tmp/trace-good.json")
def run_bad() -> None:
tools = TracedTools(
{"list_files": list_files, "patch_file": patch_file},
AssumptionBudget(max_assumed_fields=0),
)
tools.call("list_files", root=".")
tools.call("patch_file", path="prod.env", comment="looks important")
if __name__ == "__main__":
run_good()
try:
run_bad()
except PermissionError as exc:
print(exc)
将其作为回归测试来跑,而不是演示:
python assumption_trace_demo.py
python -c "import json; print(json.load(open('/tmp/trace-good.json'))['spans'][1]['arguments'])"
好的 span 应该显示 path.kind == observed 且 source_tool == list_files。坏的运行永远不应该到达 patch_file 的实现。这就是整个循环:observe、cite 或 stop。如果你只记录异常信息,下周你就会在调试同一个 assumed 路径。保留那份 JSON。
上面的分类器不调用模型。它也不应该调用模型。模型擅长提议下一个工具。它们作为自身来源的证人来说是不可靠的。用模型来生成候选调用,然后在你本地通过 TracedTools 运行这些调用。追踪是契约。模型是嫌疑人。
Disclosure: 本文是 MonkeyCode 产品推广的一部分。MonkeyCode 的免费模型访问在你想要同一份账本获得第二个提议者时很有用:相同的工具、相同的预算、不同的 completion。MonkeyCode 的免费服务器选项在假设审计本身应该脱离你的笔记本运行时很有用——span JSON 入、违规列表出——无需将审计器提升为生产控制平面。两者都不是配额、基准测试,也不是远程端会保持运行的承诺。将远程端视为另一个 span 来源。如果它没有返回一个封闭的追踪,运行就是 incomplete 的,而不是 successful 的。
一个实用的拆分是这样的。将账本保留在能看到工具字节的 runner 上。如果你需要远程归约器,只发送 span 摘要:工具名、参数名、kind、source digest、violation 字符串。除非该 payload 已经是公开的,否则不要发送原始观测数据。Digest 可以比较。Secret 不需要为了这个检查而传输。
当免费的远程端 flake 时,你仍然有 /tmp/trace-good.json 和一个 PermissionError。这对组合足以针对 Agent 开一张工单,而不是针对网络。
从最后一个 blocked span 开始,而不是从聊天开始。读取 violations。如果 assumed_fields 包含 sha 或 url,说明模型跳过一次 fetch。添加或强制那次 fetch。用相同的 fixture 重新运行。如果字段变成了 observed 但工具仍然行为不当,那你遇到的是另一个 bug:一次错误的观测,而不是缺失的观测。这个方法不捕获错误的观测。它只捕获未引用的观测。这个限制正是关键。混合的 bug 会产生嘈杂的追踪。
接下来,按参数 kind 而不是文本比对两个追踪。一行检查足矣:
python - <<'PY'
import json, sys
a = {s["name"]: s["arguments"] for s in json.load(open(sys.argv[1]))["spans"]}
b = {s["name"]: s["arguments"] for s in json.load(open(sys.argv[2]))["spans"]}
for name in sorted(set(a) | set(b)):
left = {k: v.get("kind") for k, v in a.get(name, {}).items()}
right = {k: v.get("kind") for k, v in b.get(name, {}).items()}
if left != right:
print(name, left, "->", right)
PY
/tmp/trace-good.json /tmp/trace-bad.json
如果 kind 稳定但用户可见的 bug 仍然存在,就停止调优追踪器。追踪器已经完成了它的工作。转向对 observed payload 的 eval。
允许列表存在是因为有些字段是注释性的。comment、reason 和 commit_message 通常是有意生成的。将它们放入 allow。不要把 path、owner、endpoint 或 sha 放进去,因为那些字段在健康运行中有一个来源。如果你的 Agent 必须发明一个新的文件名,让它先观测目录,然后提议一个不在列表中的名字,并将该提议记录为一个独立的、显式的 invented kind。上面的代码片段没有实现 invented。添加它而没有审查路径只是在给 guesswork 重新贴标签。
字节相等是脆弱的。./app.py 和 app.py 是不同的字符串。在 remember 之前规范化路径和 id,否则账本会把真正的引用标记为 assumed。规范化属于工具包装器,而不是模型提示词。如果你让模型"帮忙"做规范形式,你就又回到了未引用参数的状态。
预算 max_assumed_fields=0 对 draft 状态的 Agent 不友好。代码作者、标题生成器和规划者会发明字符串。他们不应该使用这个 fail-closed 设置。他们仍然可以发出标签并在事后审查它们。Fail-closed 适用于那些修改仓库、云对象或工单的 Agent。如果一次误报比一次坏写代价更高,就不要装上那个 raise。转储 span 并 page 一个人。
该方法在空观测上也是 fail open 的。返回一个 {} 的工具不会向账本添加任何东西,所以下一次调用是 assumed 的,即使运营者期望的是一个默认值。这是正确行为。空成功不是来源。修复工具。
不要把 span 转储当作合规。脱敏、保留和访问控制是分开的。这个循环只回答一个问题:一次写入是否能引用先前的读。
如果你已经有了一份父 span 契约,保持它。因果关系和溯源是不同的维度。一个子 span 可以有良好的父级关系但仍然猜它的参数。一个被猜出来的参数可以坐在一个完美的父级下但仍然发送一个坏的 patch。两种检查都跑。不要把它们合并成一个属性,仅仅是为了让 JSON 更好看。
可复用的部分是账本、标签和 blocked 状态。其余的都是适配器代码。换掉假的 patch_file,换成你真实的工具。保留那个 raise。当一个 assumed 路径出现时,你下一个调试会话将会花在缺失的观测上,而不是又一轮提示词重写。如果你想要针对同一份账本获得第二个提议者,MonkeyCode 的免费模型访问是一个可以尝试的地方;将 fail-closed 检查保留在你这一侧边界上。