当tool_call没有对应tool_result时,日志里表现为挂起而非报错。作者提出四种Agent日志异常模式:孤儿调用、无匹配结果、重试风暴、预算超时。强调先检查调用-结果配对,再改prompt。
仪表盘依然显示绿色对勾。最终用户还在等待回复。Agent 日志停留在一个搜索调用上。在此之后,再也没有任何匹配的工具结果到来。
这不是 Prompt 问题。这是缺失的 span。
每一个 tool_call span 必须产生一个 tool_result span。这条规则很简单。打破它会掩盖挂起、重试和静默超时。
扁平日志会隐藏这个间隙。后续的 token 流看起来仍在忙碌。运维人员于是去改系统 Prompt。孤立调用依然留在 trace 里。
先检查不变量,再修改 Prompt。
孤立调用(Orphan call):一个没有对应 tool_result 的 tool_call
无匹配结果(Unmatched result):一个没有前置 tool_call 的 tool_result
重试风暴(Retry storm):同一次运行内,相同的 (tool, args_hash) 重复出现
预算未达标(Budget miss):墙上时钟时间超过该 span 的截止时间
这四个检查能捕获大多数"模型冻住了"的工单。它们不评估答案质量。
把下面的片段当作一个带标签的示例。它不是生产环境的 dump。
{"ts":"2026-09-07T09:14:01.102Z","run_id":"r_18","span_id":"s1","kind":"llm","event":"assistant_delta"}
{"ts":"2026-09-07T09:14:01.440Z","run_id":"r_18","span_id":"s2","kind":"tool_call","call_id":"c_9","tool":"web_search","args_hash":"a1f3","deadline_ms":8000}
{"ts":"2026-09-07T09:14:04.201Z","run_id":"r_18","span_id":"s3","kind":"llm","event":"assistant_delta"}
三行。一次搜索。零结果。模型仍然继续输出 token。
人类读这个文件看到的是活动。配对脚本看到的却是一个挂起。
第一遍不要记录完整 Prompt。只记录配对字段。
对参数做哈希。不要存储原始 secrets。截断错误字符串。
保存为 pair_tool_spans.py。它从标准输入读取 JSONL,打印一份紧凑的报告。当不变量失败时以退出码 1 退出。
#!/usr/bin/env python3
"""Pair tool_call spans to tool_result spans. Fail on orphans."""
from __future__ import annotations
import hashlib
import json
import sys
from collections import Counter, defaultdict
from datetime import datetime
from typing import Any
def load_rows(fp) -> list[dict[str, Any]]:
rows = []
for line_no, line in enumerate(fp, 1):
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except json.JSONDecodeError as exc:
raise SystemExit(f"line {line_no}: invalid json: {exc}") from exc
row["_line"] = line_no
rows.append(row)
return rows
def fingerprint(tool: str, args_hash: str) -> str:
raw = f"{tool}:{args_hash}".encode()
return hashlib.sha256(raw).hexdigest()[:12]
def parse_ts(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def audit(rows: list[dict[str, Any]]) -> dict[str, Any]:
calls: dict[str, dict[str, Any]] = {}
results: dict[str, dict[str, Any]] = {}
dup_call_ids = 0
by_run: dict[str, list] = defaultdict(list)
for row in rows:
run_id = row.get("run_id") or "unknown"
by_run[run_id].append(row)
kind = row.get("kind")
call_id = row.get("call_id")
if kind == "tool_call" and call_id:
if call_id in calls:
dup_call_ids += 1
calls[call_id] = row
elif kind == "tool_result" and call_id:
results[call_id] = row
orphans = []
unmatched = []
budget_miss = []
storms: Counter[str] = Counter()
for call_id, call in calls.items():
result = results.get(call_id)
if result is None:
orphans.append(call)
continue
started = call.get("ts")
ended = result.get("ts")
deadline = call.get("deadline_ms")
if started and ended and deadline is not None:
took_ms = (parse_ts(ended) - parse_ts(started)).total_seconds() * 1000
if took_ms > float(deadline):
budget_miss.append({**call, "took_ms": round(took_ms, 1)})
tool = call.get("tool") or "?"
args_hash = call.get("args_hash") or "?"
storms[fingerprint(tool, args_hash)] += 1
for call_id, result in results.items():
if call_id not in calls:
unmatched.append(result)
retry_storms = [
{"fingerprint": fp, "n": n} for fp, n in storms.items() if n >= 3
]
return {
"runs": len(by_run),
"tool_calls": len(calls),
"tool_results": len(results),
"orphans": orphans,
"unmatched_results": unmatched,
"duplicate_call_ids": dup_call_ids,
"budget_misses": budget_miss,
"retry_storms": retry_storms,
}
def summarize(report: dict[str, Any]) -> int:
print(
f"runs={report['runs']} calls={report['tool_calls']} "
f"results={report['tool_results']}"
)
print(
f"orphans={len(report['orphans'])} "
f"unmatched={len(report['unmatched_results'])}"
)
print(f"duplicate_call_ids={report['duplicate_call_ids']}")
print(
f"budget_misses={len(report['budget_misses'])} "
f"storms={len(report['retry_storms'])}"
)
for row in report["orphans"][:20]:
print(
f"ORPHAN run={row.get('run_id')} call_id={row.get('call_id')} "
f"tool={row.get('tool')} line={row.get('_line')}"
)
for row in report["unmatched_results"][:20]:
print(
f"UNMATCHED run={row.get('run_id')} "
f"call_id={row.get('call_id')} line={row.get('_line')}"
)
for row in report["budget_misses"][:20]:
print(
f"BUDGET run={row.get('run_id')} call_id={row.get('call_id')} "
f"took_ms={row.get('took_ms')} deadline_ms={row.get('deadline_ms')}"
)
for storm in report["retry_storms"]:
print(f"STORM fingerprint={storm['fingerprint']} n={storm['n']}")
has_issue = (
report["orphans"]
or report["unmatched_results"]
or report["duplicate_call_ids"]
or report["budget_misses"]
or report["retry_storms"]
)
return 1 if has_issue else 0
def main() -> None:
rows = load_rows(sys.stdin)
report = audit(rows)
exit_code = summarize(report)
sys.exit(exit_code)
if __name__ == "__main__":
main()
python3 pair_tool_spans.py < traces.jsonl
echo $?
期望的健康输出:
runs=12 calls=48 results=48
orphans=0 unmatched=0
duplicate_call_ids=0
budget_misses=0 storms=0
一个挂起的搜索看起来是这样:
runs=1 calls=1 results=0
orphans=1 unmatched=0
duplicate_call_ids=0
budget_misses=0 storms=0
ORPHAN run=r_18 call_id=c_9 tool=web_search line=2
退出码 1 意味着停止。此时不要去调温度。
保存为 sample_orphan.jsonl 并通过管道传入。
{"ts":"2026-09-07T09:14:01.440Z","run_id":"r_18","span_id":"s2","kind":"tool_call","call_id":"c_9","tool":"web_search","args_hash":"a1f3","deadline_ms":8000}
{"ts":"2026-09-07T09:14:04.201Z","run_id":"r_18","span_id":"s3","kind":"llm","event":"assistant_delta"}
python3 pair_tool_spans.py < sample_orphan.jsonl; echo exit:$?
你应该看到一行 ORPHAN。你应该看到一个非零退出码。
用报告作为分诊地图。不要靠猜。
这张表是一个可复用的调试循环。先配对,再分类,然后再改一件事。
"超时"不是一种 bug。根据 trace 把它拆开。
LLM stall:N 秒内没有 token,且 outstanding 为空。
Tool stall:outstanding 不为空,且没有 tool_result 到来。
迟到结果:结果在调用方已经继续之后才到达。
迟到结果通常表现为无匹配的行。它们也可能在预算未达标之后才配对上。这两种情况需要不同的修复。
Tool stall 需要运行时看门狗。LLM stall 需要流心跳。迟到结果需要一个 call_id 墓碑,而不是再取一次样。
常见的 bug 很简单。运行时在工具仍在途时就发出 token。
伪代码如下。它不是生产代码。
outstanding = set()
def on_tool_call(call_id: str) -> None:
outstanding.add(call_id)
def on_tool_result(call_id: str) -> None:
outstanding.discard(call_id)
def can_emit_tokens() -> bool:
return len(outstanding) == 0
如果 can_emit_tokens() 为 false,就写一个 blocked_on span。不要继续流式输出。Trace 随后会解释这个等待。
重试风暴需要一个稳定的指纹。在哈希之前规范化 JSON。
import hashlib
import json
def hash_args(args: dict) -> str:
blob = json.dumps(args, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(blob.encode()).hexdigest()[:16]
不稳定的 key 顺序会产生虚假唯一性。风暴检测器就会沉默。编码集合的数组也需要显式排序。
配对不需要 payloads。它需要的是身份和哈希。
写入之前删除 Authorization header。
对工具参数做哈希。不要原始存储。
把 error_class 保持为 enum。不要保留完整 body。
在上传前一次性清除邮箱和 token。
一个免费的临时主机也还是主机。一旦文件离开笔记本,就当它是公开的。
你可以在笔记本上运行审计。一个长期的 Agent 套件需要一个保持在线的机器。
披露:本文是 MonkeyCode 产品推广的一部分。
MonkeyCode 是一个开源 coding agent,提供免费模型访问和免费服务器选项。这里这两个事实重要,原因只有一个。你可以在临时主机上保留 JSONL trace 和配对脚本。你可以用免费的模型端点重新运行失败的套件。你不需要先有一个 GPU 机器。
如果你生产环境已经有 OpenTelemetry,就继续用它。这个脚本不替代收集器。它是 Prompt 调试前的一道关卡。
它很窄。知道它的边缘。
它需要两边都有 call_id。缺失的 ID 会让每个调用看起来都是孤立的。
它信任时间戳。在没有 NTP 的情况下合并两个时钟会产生虚假的预算未达标。
它不能恢复 tool payload 内部被截断的 JSON。
它不评估答案质量。配对上的结果仍然可能是错的。
它不是分布式 trace 后端。跨主机扇出需要上下文传播。
16 位十六进制字符的哈希碰撞是极不可能的。但并非不可能。
HTTP 状态是可选的。没有它,503 看起来像模型 stall。
把这里的每个示例都标注为在你的数据上未执行过。在信任这些数字之前,先用你的文件跑一下脚本。
在少数情况下跳过这个循环。
如果你的运行时已经在 outstanding 调用时阻止生成,你可能只需要风暴检查。
call_id 发出 tool_call 和 tool_resultdeadline_mshttp_statuspair_tool_spans.py 输送 JSONL顺序就是要点。不完整的 trace 浪费模型预算。配对比再取一次样便宜。
如果你需要一个临时主机来运行这个 JSONL 循环,MonkeyCode 的免费服务器选项是放置脚本和 trace 的一个地方。