首页
/ AI 找不到执行结果?排查 _sanitize_api_messages 首尾空格引发的血案

AI 找不到执行结果?排查 _sanitize_api_messages 首尾空格引发的血案

2026-04-15 16:37:45作者:魏侃纯Zoe

诡异的死锁:明明执行了,为什么依然触发 Tool Call ID 空格解析 失败?

前天半夜,我正挂着 debugger,试图用 Hermes-Agent 跑通一个极其复杂的跨系统工单分发流。Agent 需要调用我写好的 Python 脚本去查库,然后再总结输出。

看着控制台的日志,一切似乎都完美无瑕:大模型非常聪明地吐出了函数名和参数,底层的执行引擎也立刻捕捉到了指令,我的本地脚本欢快地跑了起来,控制台赫然印着 Tool execution successful: returning JSON data

然而,令人吐血的事情发生了。

Agent 拿到了结果,却像瞎了一样原地挂起。主事件循环死死卡住,没有任何后续回复。等了足足 60 秒,终端直接炸开一团红色的 Traceback,最后抛出了一句极其脑残的报错:Invalid request: message history contains tool response without a matching tool call.

大模型说它没收到匹配的工具回复?我特么看着结果已经打进内存池了!去翻了翻 GitHub Issue #9999,我才发现这根本不是大模型的锅,而是框架底层在处理 Tool Call ID 空格解析 时,犯了一个极其愚蠢的初级错误。

报错现象总结: 当 Hermes-Agent 接入部分开源大模型(如 Qwen、DeepSeek)执行 Tool Calling 时,模型有时会在生成的 tool_call_id 首尾带上不可见的空格。而框架在将工具执行结果组装回消息历史时,没有进行任何去除空格(strip)的脏数据过滤,导致强弱类型比对失败。最终表现为工具已经执行完毕,但大模型认为该请求被遗漏,从而引发 API 拒绝服务或状态机死锁挂起。

官方文档教你配置各种炫酷的工具链,却永远不会告诉你,这种最基础的字符串脏数据,能轻易把你的高并发业务流干趴下。今天我们直接扒开源码,看看这是怎样一场由一个空格引发的血案。

扒开 _sanitize_api_messages:一行愚蠢的严格比对逻辑

要弄明白为什么执行成功了却报找不到 ID,我们必须深入 Hermes-Agent 在发包给 LLM 前的最后一道关卡:消息清洗层(Message Sanitizer)。

大模型的 API 接口(特别是 OpenAI 规范的接口)对 Tool Calling 的上下文一致性有着极其变态的要求。你发送的每一条 tool_response 消息,都必须有一个与其绝对匹配的 tool_call_id。如果对不上号,API 网关会直接把你踢掉。

为了满足这个规范,Hermes 官方搞了一个叫 _sanitize_api_messages 的核心函数。这个函数的本意是遍历整个上下文,找出那些孤立的工具调用并剔除,防止 API 报错。

但来看看这几行极其草率的原生代码逻辑:

# hermes_agent/utils/message_sanitizer.py (原生缺陷代码)

def _sanitize_api_messages(messages: List[Dict]) -> List[Dict]:
    valid_tool_calls = set()
    sanitized_messages = []

    for msg in messages:
        if msg.get("role") == "assistant" and "tool_calls" in msg:
            for tool_call in msg["tool_calls"]:
                # ⚠️ 灾难源头:直接把原始的、可能带脏空格的 ID 塞进白名单
                valid_tool_calls.add(tool_call["id"])
            sanitized_messages.append(msg)
            
        elif msg.get("role") == "tool":
            # ⚠️ 致命比对:没有任何 .strip(),严格校验字符串!
            if msg.get("tool_call_id") in valid_tool_calls:
                sanitized_messages.append(msg)
            else:
                logger.warning(f"Dropping orphaned tool response for ID: {msg.get('tool_call_id')}")
        else:
            sanitized_messages.append(msg)

    return sanitized_messages

看出这个白名单比对漏洞有多坑爹了吗?

Claude 3.5 这些“乖孩子”吐出的 ID 是极其标准的 "call_abc123"。但如果你换成国内一些对 JSON 缩进和结构有些许“自我发挥”的开源模型,它们经常会在序列化时,在 ID 后面隐蔽地带上一个空格,变成 "call_abc123 "

这时候,你的执行引擎把任务做完了,它非常乖巧地拿着清理过的标准 ID去白名单里找匹配。结果因为 valid_tool_calls 里存的是带空格的脏数据,Python 的 in 操作符无情地返回了 False

为了让你感受这种逻辑漏洞在不同模型下的灾难性差异,看下这组压测数据表现:

LLM 模型类型 吐出的原始 tool_call_id 格式 框架层存入 valid_tool_calls 的状态 终端实际表现与后果
Claude 3.5 Sonnet "call_8x9a" (无空格) "call_8x9a" (精准匹配) 完美运行,工具结果成功回调
Qwen3-Max "call_9z2b " (⚠️尾部带空格) "call_9z2b " ❌ 结果被当做孤儿丢弃,API 抛出验证报错
DeepSeek-R1 " call_5y1c" (⚠️头部带空格) " call_5y1c" ❌ Agent 无限等待,状态机死锁挂机

你的工具明明跑完了,结果在这最后一步的 _sanitize_api_messages 里,被当作“孤儿数据”给直接 Drop(丢弃)了。模型苦苦等不到结果,你的业务流直接暴毙。

手写多层级字符串消毒:深渊级别的环境折磨

病因很明确:底层代码对字符串没有做任何边界消毒。那我们要做的,就是在整个消息流转的生命周期里,强行植入 .strip()

但如果你准备自己动手在本地修这个 Bug,准备好度过一个骂骂咧咧的周末吧。

首先,钻进你那个层层嵌套的 Python 虚拟环境,找到 venv/lib/python3.11/site-packages/hermes_agent/

你以为只改 _sanitize_api_messages 里的那一行 add(tool_call["id"].strip()) 就够了吗?天真!

整个 Agent 的状态机是一个巨大的黑盒。你不仅要在消息清洗层做 strip,还要去修改底层的 Execution Layer(执行层)。当工具开始被派发时,如果存入数据库的 Task ID 带有空格,后面异步回调更新状态时,一样会因为找不到主键而引发 SQL 崩溃。

你不得不全局搜索 tool_call.id,并在多达十几处的核心网关、持久化数据库读写、甚至内存共享的代码中,小心翼翼地手敲 .strip()

一旦你漏了一处,或者在异步协程的某次回调里忘了处理脏数据,Agent 就会在运行两个小时后突然因为上下文不匹配而全盘崩溃。更要命的是,在国内这种网络环境下,如果你想引入诸如 pydantic 的自定义验证器来做全局 AOP 拦截,试图重新 uv pip install 时,各种依赖拉取超时和版本冲突会直接教你做人。

等到下个礼拜官方一更新版本,你一个 git pull,刚才改的那几十个文件瞬间全被覆盖。

降维打击:丢掉幻想,用鲁棒性工具集修复包一键接管

作为开发者,你的核心价值是去设计精妙的 Agent 业务编排流,而不是在一个开源项目的底层源码里,拿着放大镜去找哪里少写了一个去空格的 strip()

这种因为官方没有做基础数据校验而导致的弱智 Bug,凭什么要让我们的业务稳定性来买单?

为了彻底根治这个暗坑,我已经把整个底层的工具调度和消息清洗逻辑彻底重构了。我没有到处去打补丁,而是直接在工具解析的入口处,植入了一套自带严格字符串消毒机制的鲁棒性工具集拦截器

不管底层的大模型有多放飞自我,不管它在 JSON 序列化时往 ID 里塞了前导空格、换行符还是隐形的制表符,这套拦截器都会在第一纳秒进行正则级的高压清洗,确保整个 Agent 状态机拿到的永远是绝对纯净、严格对齐的唯一键值。

👉 [访问 GitCode 下载自带字符串严格消毒机制的鲁棒性工具集修复包] (搜索 Hermes-Agent 核心调度高可用补丁)

接入姿势极度傻瓜化:

  1. 点击 GitCode 链接,直接下载这个特供的补丁包(https://gitcode.com/GitHub_Trending/he/hermes-agent)。
  2. 解压后,将其中的 sanitizer_patch.py 和增强版 execution_layer.py 直接覆盖到你的项目核心目录下。
  3. 重新拉起守护进程。

覆盖完毕后,再去用那些偶尔“抽风”的国产模型跑跑高并发的工具调用。你会发现,那些原本动不动就引发死锁的孤儿请求不见了,每一条工具执行结果都能严丝合缝地贴回主线程的消息树中,控制台像齿轮咬合一样顺滑。

拿去用,别让一个看不见的空格,断送了你的 AI 落地项目。

登录后查看全文
热门项目推荐
相关项目推荐