流式输出里最怕的不是模型慢,而是第一块 chunk 还没吐出正文,适配层就先崩了。
NousResearch/hermes-agent issue #24974 记录了一个很典型的 provider 兼容问题:Gemini Code Assist streaming 在发送 reasoning-only delta 时,Hermes 的 downstream consumer 访问 delta.content,结果直接抛出:
AttributeError: 'types.SimpleNamespace' object has no attribute 'content'
场景发生在 Gemini 2.5 Flash,通过 --provider google-gemini-cli 跑 hermes chat -v 时复现。表面看是 streaming failed,底层其实是一个很小的 schema shape 问题:delta 可以没有可见正文,但不能没有 content 这个字段。
现场:thinking 已经出来,正文还没开始,streaming 先失败
issue 里的 live log 大概是这样:
Gemini Cloud Code Assist client created
[thinking] **Acknowledging your input**
I've processed your acknowledgment and am ready for your next directive...
Streaming failed before delivery: 'types.SimpleNamespace' object has no attribute 'content'
API call failed ... error_type=AttributeError
这段日志很关键。
它说明 Gemini 的 thinking / reasoning 块已经进入了流式链路,但在还没有真正可见正文 content 的时候,Hermes 下游代码尝试读取:
delta.content
如果 delta 是一个普通 dict,可能还能用 .get("content") 兜底;但这里是:
SimpleNamespace
SimpleNamespace 只会暴露创建时传进去的属性。没传 content,访问 delta.content 就不是 None,而是直接 AttributeError。
最小复现:不需要 API quota
这个 issue 的好处是,复现不依赖真实 API 调用。
只需要直接调用 adapter 里的构造函数:
from agent.gemini_cloudcode_adapter import _make_stream_chunk
d = _make_stream_chunk(model="m", reasoning="think").choices[0].delta
assert d.content is None
在修复前,这个断言不会得到 None,而是直接报错:
AttributeError: 'types.SimpleNamespace' object has no attribute 'content'
这说明问题不是网络、不是 Gemini quota、也不是模型响应质量,而是 chunk 对象构造时少了一个字段。
根因:content 只有在有正文时才被写入
问题发生在 agent/gemini_cloudcode_adapter.py 的 _make_stream_chunk()。
旧逻辑类似这样:
delta_kwargs = {"role": "assistant"}
if content:
delta_kwargs["content"] = content
if reasoning:
delta_kwargs["reasoning"] = reasoning
delta_kwargs["reasoning_content"] = reasoning
delta = SimpleNamespace(**delta_kwargs)
这段代码对普通 content chunk 没问题。
如果 content="hello",最终 delta 里有:
role
content
如果同时有 reasoning,也会有:
reasoning
reasoning_content
但 reasoning-only chunk 的情况是:
content = "" 或 None
reasoning = "think"
于是 if content: 不成立,content 不会进入 delta_kwargs。
最终 delta 只有:
role
reasoning
reasoning_content
没有:
content
下游一访问就炸。
为什么 reasoning-only delta 很正常?
很多新模型的流式协议不再是“每个 chunk 都有 visible text”。
它可能先发:
reasoning / thinking / thought / analysis
然后才发:
content / text / answer
也可能中间穿插:
tool_call_delta
这意味着适配层不能假设每个 chunk 都有可见正文。
但它应该保证:对外伪装成 OpenAI-style ChatCompletionChunk 时,字段形状要稳定。
也就是:
delta.content 可以是 None
但 delta.content 这个属性应该存在
这就是兼容层和业务层之间的契约。
一行修复:默认给 content 填 None
issue 给出的修复非常小:
- delta_kwargs = {"role": "assistant"}
- if content:
- delta_kwargs["content"] = content
+ delta_kwargs = {"role": "assistant", "content": content or None}
这样 reasoning-only chunk 也会得到:
delta.content is None
而不是:
AttributeError
为什么用 None 而不是空字符串?
因为 OpenAI-style stream delta 里,content 常见语义是:
Optional[str]
没有新增正文时,用 None 比 "" 更能表达“这一块没有可见文本”。
tool-call-only chunk 也要一起测
issue 里还提到了一个很好的 regression test 思路:不要只测 reasoning-only。
应该覆盖三类 chunk:
from agent.gemini_cloudcode_adapter import _make_stream_chunk
cases = [
("reasoning-only", dict(model="m", reasoning="think", content="")),
("content-only", dict(model="m", content="hello")),
("tool-call-only", dict(
model="m",
tool_call_delta={"index": 0, "name": "foo", "arguments": "{}"},
)),
]
for label, kwargs in cases:
d = _make_stream_chunk(**kwargs).choices[0].delta
assert hasattr(d, "content"), f"{label}: missing .content"
原因很简单:tool-call-only chunk 也可能没有可见正文。
如果只修 reasoning-only,而不建立“delta schema 必须完整”的测试,类似问题还会在 tool call、function call、metadata-only chunk 上复发。
为什么日志里会出现 retry,反而更难定位?
issue 里提到,AttributeError 被分类成:
reason=unknown
retryable=True
于是 Hermes 会重试。
重试机制本身是好东西,但在这种本地 adapter bug 上,它可能让现象变复杂:
- 第一次真正的错误是 missing
.content; - 后续重试可能撞到 quota、429、超时;
- 用户最后看到的错误变成 provider rate limit;
- 根因被埋在第一段 streaming failed 日志里。
所以遇到 streaming 问题时,要特别留意第一条异常,而不是只看最后一次 retry 的结果。
相关修复:完整 delta schema 才是重点
评论里维护者提到,这类问题可能已由相关修复覆盖:
#21856 fix(cloudcode): always populate full delta schema in stream chunks
#21609 fixes content field omission in Gemini CLI ACP path
关键词是:
always populate full delta schema
也就是说,不只是补一个 content 字段,而是要让 stream chunk 对外呈现稳定结构。
对 Hermes 这种要同时适配多 provider、多 CLI、多模型输出形态的系统来说,稳定 schema 比“刚好能跑通某个模型”更重要。
和 OpenAI-compatible API 的关系:看的是同一层契约
如果你把 Hermes 接到 OpenAI-compatible API,或者通过 DeepAI API 中转站统一管理 Base URL、API Key、模型路由和调用统计,streaming 兼容层也要遵守类似原则。
模型可以有不同的内部事件:
reasoning
content
tool_call
metadata
usage
但对上层 agent loop 来说,最好被规整成稳定的 chunk shape。
例如:
delta.content 始终存在,值可以是 None
delta.tool_calls 始终存在,值可以是 None 或 []
delta.reasoning_content 有就填,没有就 None
这样上层消费逻辑可以专注处理语义,而不是到处写 hasattr(delta, "content")。
DeepAI API 中转站适合放在这一层之后观察模型调用:请求是否成功、流式是否持续、错误码是什么、模型是否可用、延迟和用量是否异常。adapter 的 chunk schema 则是进入 provider 前后都要保持一致的接口契约。
排查 Gemini streaming AttributeError 的顺序
如果你遇到类似:
Streaming failed before delivery: 'types.SimpleNamespace' object has no attribute 'content'
可以按这个顺序看:
1. 找第一条异常,不要只看最后一次 retry; 2. 确认是不是 reasoning-only 或 tool-call-only chunk; 3. 检查 adapter 构造 delta 时是否始终写入 content; 4. 对 _make_stream_chunk() 写无 quota 的最小复现; 5. 用 regression test 覆盖 reasoning-only、content-only、tool-call-only; 6. 再去看 provider quota、429、网络超时等外部因素。
这个顺序能避免把本地 schema bug 当成模型服务不稳定。
总结
#24974 的根因不复杂:
_make_stream_chunk() 只在 content truthy 时写入 delta.content。
Gemini reasoning-only chunk 没有 visible content。
SimpleNamespace 缺少 content 属性。
下游访问 delta.content 时抛 AttributeError。
修复也很小:
delta_kwargs = {"role": "assistant", "content": content or None}
但它背后的经验很重要:
streaming adapter 不能假设每个 chunk 都有正文;
却应该保证每个 chunk 都有稳定的字段形状。
当模型开始输出 reasoning、tool call、metadata 等多种事件时,兼容层的 schema 稳定性就是 agent 能不能持续工作的地基。