适用环境:Windows 11 上的 Hermes Agent(0.21.0)日常使用,另有一个承载定时任务的 Docker 容器(Linux);时间 2026 年 9 月上旬。四则故障摆在桌面上时都像「模型坏了」——空响应、偶发 400、模型列表消失、定时任务报错——查到最后全部落在预算、端点、进程状态、身份权限四类非模型问题上。结论:症状出现在模型层,根因往往在模型层之外;先取系统事实(token 用量、请求落盘、PID、文件属主),再动手改。
为什么会连续遇到这一类问题 #
AI Agent 把「调用模型」变成了一次复杂编排:多模型并行、多通道可选、配置热改、进程常驻、定时任务换身份执行。任何一环与模型的接口假设不一致,故障都表现为「模型不干活」,因为诊断入口天然在模型层。四则案例时间紧挨着出现,故障面各不相同,但有一个共同点:第一现场的证据全部在模型层之外——token 用量、请求落盘、PID 与文件时间戳、文件属主与调度身份。把「是不是模型坏了」当成待验证的假设而非事实,先翻这四个事实源,大概率在第一页证据里就能定位。另一条主线是复现纪律:用与故障完全相同的代码路径、相同的身份、相同的通道去复现,一半的疑难在复现那一刻就结束了。速查表如下:
| 症状 | 真因 | 一条命令验证 |
|---|---|---|
| 聚合/多槽输出全空,聚合器声明「参考响应均为空」 | 参考槽思考强度与输出预算不匹配,思考 token 吃干预算 | 查用量账:reasoning_tokens 接近 completion_tokens |
| 推理模型 + 工具偶发 400 | 请求走了 /v1/chat/completions,上游要求走 /v1/responses |
翻请求落盘 JSON:request.url 是否指向 /v1/responses |
| 改好的模型列表在桌面端看不到,新对话无法调用 | 后端进程启动早于改动,模型目录在内存里是旧表 | 比 PID 启动时间与文件改动时间 |
定时任务写状态文件 PermissionError,手动跑却正常 |
调度身份(uid 1000)与文件属主(root)不一致,且异常被静默吞掉 | 用 docker exec -u <uid>:<gid> 复现 |
一、专家模型无响应:思考把输出预算吃干 #
症状与第一手数据 #
多专家协作(多个参考模型并行起草意见,一个聚合模型汇总)中,一次「深度」预设的会诊里,聚合器反复出现如下声明:
「我看到 MoA 参考模型的响应都是空的」「参考模型响应均为空,我作为聚合模型直接…」
参考环节等于零信号,整轮输出实际来自聚合器单干。而用量账显示这 6 次调用真实烧掉了 76K 输入 / 10K 输出 token——模型确实在算,只是算出来的东西根本没有进入上下文。
更精确的证据来自对真实调用路径的探针:用与故障完全相同的代码(参考槽调用函数)复跑三个参考槽,两个槽的返回原文如下:
实测返回(思考 token 与可见内容占比)
参考槽 B: completion_tokens=1200, reasoning_tokens=1193 → content=''(空)
参考槽 C: completion_tokens=1200, reasoning_tokens=1200 → content=''(空)1200 的输出预算被思考(reasoning)吃干抹净:思考 token 占满预算(1193–1200),可见内容为 0。
根因 #
参考槽被配置为高思考强度(reasoning_effort: high)+ 很小的输出预算(reference_max_tokens: 1200)。思考模型在把预算烧完之前不会产出可见内容;1200 的上限恰好到「想完为止」。默认档预设没炸,是因为思考强度只有 low/medium,占不满预算。关键机制有二:
- 参考模型的思考对聚合器完全不可见——参考槽从不行动,只有可见 content 会进入聚合器上下文。思考烧掉的 token 对本轮输出是纯粹的沉没成本。
- 深度应该属于聚合器——聚合器拿到全量上下文、高思考强度与顶格预算(本例 8192),最终质量由聚合器对多样输入的合成决定;参考槽共享的是裁剪后的意见片段,经验数据里参考输出 token 与整轮延迟强相关(约 0.88),给参考狂加预算只会线性抬高延迟与成本,答案质量几乎不涨。正确姿势是:参考槽低强度、够用预算,深度全部押在聚合器。
处置过程(含弯路) #
第一轮按真实请求形态单发流式探测(stream + max_tokens=1200 + high)走出了弯路:参考槽 A 正常返回 12194 字符;参考槽 B 返回 1673 字符但 finish_reason=length(被 1200 截断);参考槽 C 在探测环境被限流 403。单发都不空,一度怀疑「全空」来自别的环节。换用与故障完全同一的代码路径(真实的参考槽调用,含参数组装、缓存装饰、响应提取)复跑三个槽,空响应稳定复现:A 返回 11075 字符,B、C 均返回 (empty response)。
教训:手工单发与真实调用路径的差别(参数组装、超时、提取逻辑)足以改变结果。复现必须用真实代码路径。
修复与验证 #
改法:三个参考槽思考强度 high → medium,reference_max_tokens 1200 → 2400;聚合器保持 high / 8192 不动。深度留给聚合器,参考只求多样性。修改后的配置片段:
deep 预设修复后的配置(模型名隐去,结构按原样)
moa:
presets:
deep:
reference_max_tokens: 2400 # 1200 → 2400
max_tokens: 8192 # 聚合器预算,不变
degraded_reference_policy: loud
reference_models:
- provider: <参考通道> # 三个参考槽
model: <参考模型 A>
reasoning_effort: medium # high → medium
- provider: <参考通道>
model: <参考模型 B>
reasoning_effort: medium
- provider: <参考通道>
model: <参考模型 C>
reasoning_effort: medium
aggregator:
provider: <参考通道>
model: <聚合模型> # 深度留给聚合器
reasoning_effort: high # 不变验证口径:用同一段真实代码路径、同一批槽位复测,比较 content 字符数与空响应个数。修复后三个参考槽全部非空(10260 / 1026 / 3129 字符),空响应消除。此前唯一幸存的参考槽 A 从 11075 字符降到 10260,结构化分析完整保留,只是少了冗余——另外两个槽从 0 变成实打实的意见。反向口径同样明确:若复测仍出现 (empty response),或字符数不升反降,说明预算依然不够、该模型的思考开销更大,继续加 reference_max_tokens,而不是动 effort。若再想加深参考,加预算,别加 effort:小预算下 effort 只会把预算变成隐形思考。
二、间歇 400:推理模型 + 工具走错了端点 #
症状与第一手数据 #
一个 OpenAI 兼容的中转服务(下称「中转站」)上,推理模型带工具调用时报 400,错误原文:
400 错误原文
HTTP 400: Function tools with reasoning_effort are not supported for <上游模型名>
in /v1/chat/completions. To use function tools, use /v1/responses or set
reasoning_effort to 'none'. (request id: <已隐去>)同一模型同一配置,失败却是间歇性的:连续 6 次请求出现 1 次 400,另一轮连续 8 次出现 1 次 400,多轮探测估算失败率约 5%。故障请求的落盘 JSON 显示:url 是 …/v1/chat/completions,body 同时携带了 tools 与 reasoning_effort——这正是「该走另一端点却走了这个端点」的现场。
根因 #
客户端有个值得先说的设计:请求失败时会连同 url、headers、body 落盘成 JSON(request_dump)——这是取证的第一现场,症状出现先翻它,而不是盯着错误码猜。本例的落盘记录表明请求确实带了 tools 与 reasoning 却发往 chat/completions 端点。上游通道对 OpenAI 系推理模型的规则:带 function tools 时,非 none 的推理强度只能走 /v1/responses,/v1/chat/completions 一并携带二者会被拒。客户端把自定义 provider 默认路由到 chat/completions,于是撞上上游约束。更迷惑的是模型粒度不一致:同端点同配置,一个推理模型 high 与 none 全部 200,另一个推理模型 high 稳定 400(改成 none 又全部 200)。模型间差异叠加同模型间歇失败,观感就是「随机故障」。
处置过程(含弯路) #
第一版改法是错误文案提示的官方建议:给这些模型设 reasoning_overrides: none,让 chat 通道的请求不携带推理强度。实测没有真正消除 400——中转仍按上游通道施加推理——而且代价是关掉思考,不可接受,撤销。
教训:错误文案的「官方建议」只对特定通道成立;中转层叠了上游策略,改法要先用该通道直连探测确认,不能照单全收。
修复与验证 #
纯配置修复、源码零改动:在中转站同源(同一 base_url、同一密钥)新增一个走 Responses 协议的 provider,请求路由到 …/v1/responses,可同时携带 tools 与 reasoning;原 chat 通道保留,全局推理强度不动。匿名化后的配置片段:
新增 Responses 通道(匿名化版本,实际名字已隐去)
providers:
<普通通道>: # 原有配置,保持 chat/completions 不变
base_url: "https://api.example.invalid/v1" # 域名与端口隐去
key_env: <密钥环境变量>
discover_models: true
<responses 通道>: # 新增:同一中转的 /v1/responses 通道
name: "<中转站名>(Responses)"
base_url: "https://api.example.invalid/v1" # 与普通通道同源
key_env: <同一个密钥环境变量>
api_mode: codex_responses # 请求发往 /v1/responses
discover_models: false
model: <推理模型>
models:
<推理模型>: {}
<另一个推理模型>: {}
# 只登记实测 /responses 可用的模型保留两条通道的原因来自一张通道矩阵实测(同中转、同密钥、双端点各打一轮):
| 模型类别 | /v1/chat/completions |
/v1/responses |
结论 |
|---|---|---|---|
| OpenAI 系推理模型 | 带 tools + 推理间歇 400(约 5%),effort=none 稳定 200 |
带 tools + 推理稳定 200 | 走新通道 |
| 另一类模型(多模态对话) | 稳定 200 | 稳定 429(「当前分组上游负载已饱和」) | 保持 chat |
| 第三类模型(某国产旗舰) | 稳定 200 | 稳定 429 | 保持 chat |
矩阵里 429 的提示原文是「当前分组上游负载已饱和」——这是中转按分组转发失败的通用提示,并不代表模型本身坏了;判断通道可用性以端点的真实返回为准。
验证分两层:功能上,两个曾出 400 的模型经新通道带工具均跑通(其中一个再做两轮强制工具调用,均正常执行并回传输出);结构上,翻故障请求的落盘 JSON——request.url 已变成 …/v1/responses,body keys 含 model / instructions / input / tools / reasoning。这就是「请求确实换了端点」的物证。
检视请求落盘的命令
import json, glob, os
f = sorted(glob.glob("<状态目录>/request_dump_*.json"),
key=os.path.getmtime)[-1]
d = json.load(open(f, encoding="utf-8"))
r = d.get("request") or {}
print("url:", r.get("url")) # 应指向 .../v1/responses
b = r.get("body") or {}
print("body keys:", list(b.keys())) # 应含 tools / reasoning顺带提醒:这类「同源双通道」方案要维护两份模型登记表,新模型上架时先实测它的 Responses 通道可用性再入表,别把 429 的模型硬塞进去。
三、模型列表「被刷新掉了」:运行中的进程还在用旧内存态 #
症状与第一手数据 #
某厂商官方 API 灰度发布临时内测模型(模型名自带过期标记,如 …-expires-on-<日期>)。官方 /models 接口只列 GA 模型、不收录灰度名,但拿模型名直接打其 chat/completions 返回 200,推理输出正常。于是把模型写进本地模型目录(catalog 静态表)、把默认模型切到它。此后:
- 新进程里模型列表(权威构建函数)返回 4 个模型,新模型在列;
- 命令行一次性调用也能跑通;
- 但桌面端模型列表里看不到它,新对话无法调用。
用户的观感是「模型列表被刷新掉了」。
根因 #
模型目录(提供模型清单的静态表)在进程 import 时整表载入内存,运行中的进程不会重新读文件。排查链逐项排除后只剩一处不过:
| 检查点 | 结果 |
|---|---|
| 官方端点是否还认这个模型名 | ✅ 200,参数组合正常 |
| 配置文件默认模型 | ✅ 仍是新模型 |
| 静态模型目录(catalog 源文件) | ✅ 条目还在,git 改动未被冲掉 |
| 权威列表构建(新进程) | ✅ 返回 4 个模型,含新模型 |
| 命令行一次性调用(新进程) | ✅ 跑通 |
| 正在运行的后端进程 | ❌ PID 644 / 7880(serve)启动于当天 09:00:07,早于文件改动(当天 15:44) |
判据就是最后一行:进程启动时间早于文件改动时间。磁盘上的一切(列表、配置、官方端点)都是对的,错的是常驻进程内存里那份 import 时载入的旧表。
处置与验证 #
修复 = 让后端进程重启加载新状态:用一次性计划任务在 2 分钟后执行 Stop-Process 杀掉 serve 进程,桌面应用会自动拉起新后端(当次对话会断一下,属预期)。
这类「import 时载入的静态表」不吃配置热加载——配置文件的修改多数键可以热生效,但模型目录这类进程级常量只在启动时快照一次。所以改了目录后的第一动作就是重启验证,而不是反复检查文件内容。工程上先确认「老进程确实死在改动之前」再看其它方向,这一步用一条命令就能盖棺:
查验进程启动时间(Windows)
powershell.exe -NoProfile -Command \
'Get-CimInstance Win32_Process | Where-Object { $_.Name -match "python" } | \
Select-Object ProcessId, CreationDate, CommandLine | Format-List'
# 输出形如:PID=644 START=09/08/2026 09:00:07 ... -m hermes_cli.main --profile default serve# 与文件改动时间比对(示例)
stat -c '%y' <hermes 数据目录>/config.yaml # 若晚于进程启动时间,就是内存态问题调度重启(Windows 计划任务,名字已隐去)
schtasks /create /tn "<任务名>" \
/tr "powershell.exe -NoProfile -Command \"Stop-Process -Id <旧PID> -Force\"" \
/sc once /st <HH:MM> /f如何确认已经生效:重启后新开一个对话,模型标签显示新模型名即成功;若仍失败,回到排查链最后一项重新比照启动时间,或检查该灰度名是否已到期(名字里的过期标记过期后调用会报模型不存在,届时切回正式模型并还原目录条目即可)。
四、定时任务的隐形权限坑:调度身份 ≠ 你手动跑的身份 #
症状与第一手数据 #
定时任务通知里只有被截断的 traceback(Script exited with code 1);任务记录 JSON 的 last_error 里有完整原文:
任务记录里的完整错误(路径已匿名化)
Traceback (most recent call last):
File "<部署目录>/<脚本名>.py", line 106, in <module>
save_state(state)
File "<部署目录>/<脚本名>.py", line 45, in save_state
with open(STATE_FILE, "w", encoding="utf-8") as f:
PermissionError: [Errno 13] Permission denied: '<部署目录>/<状态文件>.json'脚本在发现有新版要上报、准备写状态文件时崩溃。而手动在容器里跑同一脚本:正常检测到新版、退出码 0。第一反应自然是「脚本没问题,是调度的问题」——方向对了一半,但真正的坑在下面两层。
根因 #
两层错位叠加:
- 身份错位:容器内定时任务的调度器以 uid 1000(容器内服务用户)执行脚本,但脚本与状态文件是经宿主机 root 写入的(bind mount 下
root:root 0644),而整个数据树其余部分都是1000:1000——只有脚本目录孤零零挂在 root 名下。bind mount 的属主来源值得多说一句:宿主机 root 写入的文件挂进容器后仍是 root 属主,容器内非 root 调度身份自然写不动;数据树其余文件由容器内服务进程(uid 1000)自己创建,属主正确——所以整棵树只有脚本目录异常,特征非常清楚,一眼就该往身份错位方向查。cron 一写就PermissionError;手动docker exec默认以 root 跑,当然成功,把问题盖住了。 - 静默错位:脚本里把「追加侧车记录」包在
except Exception: pass里——追加失败不报错、不退出、无日志。以 uid 1000 端到端验证时功能正常,但侧车应写入的第二条记录没有出现;靠对比追加记录条数才发现失败被静默吞掉。
脚本里静默吞异常的原样片段
try:
with open(SIDECAR, "a", encoding="utf-8") as f:
f.write(...)
except Exception:
pass # ← 追加失败被静默吞掉:不报错、不退出、无日志处置 #
先复现,再修复,最后端到端验证,全程用调度身份:
复现 + 修复(命令已匿名化,结构按原样)
# 1) 以调度身份复现(关键:-u 指定 uid:gid,别用默认 root)
docker exec -u 1000:1000 <容器> python3 -c \
"open('<部署目录>/<状态文件>.json','a').write('')"
# → PermissionError: [Errno 13] Permission denied ← 坐实身份问题
# 2) 修复属主:脚本 + 状态文件
docker exec <容器> chown 1000:1000 \
<部署目录>/<脚本名>.py <部署目录>/<状态文件>.json
# 3) 连带修复:侧车记录文件同样 root 属主,一并 chown 并实测追加
docker exec <容器> chown 1000:1000 <部署目录>/<侧车记录文件>.md
docker exec -u 1000:1000 <容器> sh -c \
"echo test >> <部署目录>/<侧车记录文件>.md" && echo APPEND_OK顺手把脚本里的静默吞异常清掉:失败至少留日志,或直接让写失败可见——「不报错的失败」比报错的失败难缠一个量级。
验证 #
以调度身份端到端复现完整写路径:临时把状态里的「最新已见」回退一条制造「新版」→ 以 uid 1000 跑完整脚本 → 正常检测、上报、重存状态、退出码 0;侧车追加也实测通过(APPEND_OK)。最后留一条验收纪律:手动跑成功 ≠ 定时跑成功——最终要等下一次自动调度的结果(看任务记录里的 last_status / last_error),而不是再手动跑一遍。
五、留给后来者的清单 #
| 场景 | 先查什么 | 再做什么 |
|---|---|---|
| 聚合/多槽响应全空 | 用量账:reasoning_tokens 是否接近 completion_tokens |
降思考强度或加预算;想加深时加预算,别只加 effort |
| 推理模型 + 工具偶发 400 | 请求落盘 JSON 的 url 与 body | 按上游要求同源加 /v1/responses 通道,先实测该模型双端点再入表 |
| 改配置 / 目录后界面不变 | 进程启动时间 vs 文件改动时间 | 重启常驻进程,再验证;对比用同一数据源 |
| 定时任务与手动结果不一致 | 调度身份 uid/gid 与文件属主 | 用 -u <uid>:<gid> 复现;清掉 except: pass;等下一次自动跑验收 |
| 一切复现的纪律 | 用故障同一条路径、同一个身份、同一个通道 | 别用手工近似;复现成功的那一刻,根因通常已经水落石出 |
六、碎碎念 #
四件事共用一条主线:故障都「长得像模型坏了」,因为诊断入口都在模型层;但每一件都在模型层之外留有确凿的系统事实——token 账、请求落盘、进程时间戳、文件属主。排障真正的分水岭不是知道更多 API 细节,而是把「复现」做成纪律:同代码路径、同身份、同通道。单发探测全绿、docker exec 跑通、新进程一切正常——这三句「看起来没问题」恰恰是前三则案例里最贵的弯路。