Files
HomeAgent/docs/zh/c-core/sse-codec-c.md
JianFeeeee 7a1322c97f perf(api): SSE 导航层返工 —— 5+ 次边界压成 1 次(三项赢,仍默认关闭)
按 sse-codec-c.md §6.4 的架构改造方向返工。**部分成功**:从「五项全输」
变成「三项赢 / 一项持平 / 一项输」,且所有场景分配数都下降。

## 改造内容
| 项 | 前 | 后 |
|---|---|---|
| cgo 边界次数 | 5+(每字段一次 findKey) | 1(ha_sse_chunk_locate) |
| 键查找 | 每键各扫一遍对象(6 趟) | 单趟分派(遍历成员表一次即分发) |
| 解码 | 每字段一次往返 + 各自 decBuf | 同一趟内写进一块 sbuf(1 次分配) |
| 成员表遍历 | 6 趟 | 2 趟(顶层 + delta) |

顺带修掉两处自造的浪费(都是「先扫一遍拿个数、再扫第二遍拿首元素」):
choices 数组的「数个数 + 取首元素」合一趟;choice0 内的 delta/finish_reason
合一趟。成员遍历实测 107ns/趟,省一趟就是省 107ns。

新增 ha_sse_chunk_locate:一次调用完成根校验 + 顶层分派 + choices[0] +
delta 分派 + content/reasoning/finish 解码,输出写调用方持有的 C 结构体
(C 结构体无 Go 指针 ⇒ 可安全传指针,消除 out-param 逃逸)。
choices_count>1 时直接回退(Go 侧 Unmarshal 会解析全部元素,本层只认 [0],
其余元素可能类型不符而让 Go 整块作废 ⇒ 无法保证等价)。

## 实测(50000 次 × 3 轮取中位)
| 场景 | Entry | GoOnly | 判定 |
|---|---|---|---|
| content_zh | 1540ns / 5allocs | 1871ns / 13allocs | 快 18%,分配 -62% |
| content_ascii | 1250ns / 5allocs | 1304ns / 13allocs | 持平,分配 -62% |
| finish | 820ns / 6allocs | 921ns / 12allocs | 快 11% |
| usage | 2530ns / 9allocs | 2591ns / 12allocs | 持平偏快 |
| toolcall | 3450ns / 20allocs | 3000ns / 21allocs | 慢 15% |

## toolcall 仍输的根因(已定位,非猜测)
分解测量:C 侧纯 C 零边界 = 766ns;Go 侧 []openAIToolCall unmarshal =
1305ns/15allocs;对照 Go 整块 unmarshal ≈ 2980ns。
问题在第二行:tool_calls 元素是对象,Arguments interface{} 需要真实的
map[string]interface{},必须走 encoding/json 的反射建树。
而为了定位已先做了一遍 C 扫描 ⇒ 同一份数据被解析了两次。
⇒ 不是 C 慢,是「扫两遍 vs 扫一遍」。
标量字段(content/reasoning/finish)C 能一次到位 ⇒ 那些场景赢;
需要建树的字段(tool_calls/usage)C 的定位是纯开销。

## 为什么仍默认关闭(理由充分,不是保守)
1. toolcall 是真实负载最常见的一类块(任何一次工具调用流),仍慢 15%
2. 18% 收益不足以抵消「与 encoding/json 语义并存的第二实现」的风险
3. 本刀原始动机在 toolcall 场景没有兑现:分配数 20 vs 21 几乎没降
⇒ 前提是先做「按字段类型决定是否 C 化」,让 toolcall 也不输,再重测。

## 正确性
6 万+ 差分用例(协议形态/真实负载/随机 JSON 3 万/随机字节 3 万)全过。
基准测量也修了:先前 C 基准脚本用 CLOCK_MONOTONIC 却只取 tv_nsec,
算出 -4201ns 的负值 —— 测量工具本身出错会直接毁掉结论。

## 验证
ASan+UBSan PASS;gcc+clang 零告警;arm64 交叉 0 告警;
libFuzzer 66 万次零崩溃;全量 go test 38 包 ok / 0 FAIL
2026-09-26 10:41:52 +08:00

433 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 内核 C 化 · 第二刀:SSE 分块协议编解码
> 分支:`feature/c-core`(承接第一刀,见 `llm-orchestration-c.md`)
> 状态:**扫描/取值库已落地并闭环**(2026-09-26)。
> 基础设施已建成(`plan.md` §七),本文记录第二刀的**判据**、
> **Go 侧真值表**、**不可协商的约束**与**落地记录**。
>
> 本刀范围(有意收窄):**只交付 `ha_json_scan` 库 + 与 Go 的逐值对照**。
> **尚未**改动 Go 生产路径(`parseOpenAICompatibleStreamChunkFull` 仍是原实现)——
> 接线是独立一步,需单独验证与基准,避免「库还没验就换产线」。
---
## 一、为什么是 SSE 分块编解码
判据不是「哪个看起来底层」,而是「**在真实负载下值不值**」。
`parseOpenAICompatibleStreamChunkFull` 是**每个流式 chunk 都要跑一次**的最热路径。
实测(`feature/c-core`,153 字节 content 块):
| 输入 | ns/op | allocs/op |
|---|---:|---:|
| content 块(含中文) | 1937 | 13 |
| toolcall 块 | **3122** | **21** |
| usage 块 | 2464 | 12 |
| 纯字节扫描理论下限 | **133** | 1 |
差距 **15–23×**。一次 1 万块的会话 = 1–2 万次堆分配 —— 这正是 C 化的原始动机
(消除 GC 抖动)。
> 注:真值表探针(`zz_truth_test.go`,临时)测出 `usage` 块 2464ns/12 allocs,
> 而上面表格里 153 字节的 content 块是 1937ns/13 allocs。两者接近,但**探针的
> usage 输入 248 字节更大**,说明这张表只看数量级,具体值随输入形状浮动。
---
## 二、★ Go 侧真值表(本刀的**规格**)
C 实现不是「重新设计」,是**逐值复刻 Go**。而 Go 的 `encoding/json` 语义里
藏着一批**不直观的行为**——先探明再写 C,否则会造出一个「看起来对」的错实现。
以下全部为实测(`go test -run TestGroundTruth`):
### 2.1 键匹配是**大小写不敏感**的
```
{"choices":[{"DELTA":{"CONTENT":"up"}}]} → 解析成功,content="up"
{"choices":[{"delta":{"content":"x"},"FINISH_REASON":"stop"}]} → done=true
```
★ 极易踩:手写解析器若逐字节比对键名,这两种输入会**静默返回空内容**。
必须走「键长度 + 大小写不敏感比较」。
### 2.2 类型不匹配 ⇒ **整块作废**(不是「该字段降级为空」)
```
{"choices":[{"delta":{"content":{}}}]} → FALSE(整块拒绝)
{"choices":[{"delta":{"reasoning_content":123}}]} → FALSE
{"usage":{"prompt_tokens":"1"}} → FALSE
{"usage":{"prompt_tokens":1.5}} → FALSE
{"usage":{"prompt_tokens":1e2}} → FALSE
{"usage":{"prompt_tokens":99999999999999999999}}→ FALSE(溢出 ⇒ 报错)
{"choices":[{"delta":{"content":"x"},"finish_reason":42}]} → FALSE
```
★ 这是本刀**最反直觉**的一条:Go 侧「某字段类型不对」**不是**忽略该字段,
而是让 `json.Unmarshal` 整体失败、`parseOpenAICompatibleStreamChunkFull` 返回 `false`,
于是该 chunk 被 `continue` 静默丢弃。
⇒ 后果:上游若发来一个 usage 心跳块(只有 `prompt_cache_hit_tokens`、
`prompt_tokens_details`,没有 `prompt_tokens`/`total_tokens`/`prompt`),
**整块被丢弃**。实测确认:
```
{"usage":{"prompt_cache_hit_tokens":5,"prompt_tokens_details":{"cached_tokens":7}}} → FALSE
```
这在语义上「无害」(那个块本来也只有缓存细节),但它说明一件事:
**C 侧若比 Go 宽松,会让本来被丢的块开始生效,token 统计口径就变了**。
### 2.3 `content` 是 `interface{}`,走 `stringifyContent`
| 输入类型 | 结果 |
|---|---|
| `"hi"` | `"hi"` |
| `null` | `""` |
| `123` | `"123"` |
| `[{"type":"text","text":"a"}]` | `"a"`(数组取每个对象的 `text` 拼接) |
| `{}` | **整块 FALSE**(默认分支 `json.Marshal` 后 unmarshal 失败) |
### 2.4 `reasoning_content` 是**强类型 string**
`123` ⇒ 整块 FALSE(与 `content` 的宽松形成对比)。空 `delta` 正常通过。
### 2.5 语法严格性
`{` / `{"a":}` / ``(空)/ `null` / `[]` / `"str"` / `123` / `{"a":1,}`(尾逗号)/
`{'a':1}`(单引号)**全部 FALSE**。
★ 顶层非对象必须 FALSE(`json.Unmarshal` 到 struct 会报
`cannot unmarshal array into Go value of type struct`)。
### 2.6 重复键:**后者胜**(与 `ha_json.c` 相同)
```
{"choices":[{...content:"a"}],"choices":[{...content:"b"}]} → content="b"
{"usage":{"total_tokens":1},"usage":{"total_tokens":2}} → total=2
```
### 2.7 `finish_reason` 语义
| 值 | 结果 |
|---|---|
| `null` | `Done=false`(指针为 nil) |
| `"stop"` | `Done=true`, `FinishReason="stop"` |
| `""` | `Done=false`(**空串不算终止信号**,注释说明是 sensenova 每块都发 `""`) |
| 缺失 | `Done=false` |
| `42` | 整块 FALSE |
### 2.8 非法 UTF-8:Go 侧替换为 U+FFFD
```
{"content":"\xff\xfe"} → content="\uFFFD\uFFFD"(两个替换字符)
```
⇒ C 侧的 `\uXXXX` 与字符串取值必须与 Go 的替换语义一致
(`utf8.RuneError` 编码为 `EF BF BD`,**一个非法字节 = 一个 U+FFFD**,
不是按序列整体丢弃)。
### 2.9 转义
`\" \\ \/ \n` 等正常解码;`你好😀` 直接 UTF-8 透传。
---
## 三、不可协商的约束
1. **只吃 `(const char*, size_t)`**,不要求 NUL 结尾(否则又是 `strlen` + 拷贝的
老问题,见第一刀 §7.1 的 82% 自找开销教训)
2. **不 malloc**:结果用 **span(指针+长度)** 回给调用方,Go 侧零拷贝切片
3. **无状态纯函数、线程安全**
4. **顶层**:`ha_json_scan_chunks` 出 (span × N, 浅扫,含字符串内的 `{`/`}`,
让**顶层逗号分隔**可被切分——这正是协议层的需要:内容里的逗号不该错切顶层)
5. **语法**必须与 `encoding/json` 一致(含尾逗号非法、`null`/标量顶层非法、
重复键后者胜、大小写不敏感键匹配)
### 设计:scan(结构) + extract(取值) 两段分离
理由:`content` 可能是一个**很大**的多模态数组;而 `stringifyContent` 只需要
「text 字段拼起来」。若 scan 阶段就为每个字符串做 `\u` 解码并分配缓冲,
就等于把「解码」付给了不需要它的调用方。
故:
- **scan**:只出结构 span(键 span / 值 span)。零分配、零解码。
还要能**二次进数组内部**(取 `text` 字段)——故 API 需 `ha_json_skip`。
- **extract**:按 span 取值。字符串解码 (\u + 非法字节替换)、整数、布尔分别独立函数。
---
## 四、落地记录(2026-09-26)
| 项 | 状态 | 证据 |
|---|---|---|
| Go 侧真值表 | ✅ | 本文 §二(含三处「纠正自己的错表」) |
| `ha_json_scan.{c,h}` | ✅ | 零分配、span 返回、scan/extract 两段分离 |
| C 契约测试 | ✅ | `test_ha_json_scan.c`:**119 项断言全过** |
| 黄金对照(逐值比对 Go) | ✅ | `codec_jsongolden_test.go`:语法/成员/解码/整数/随机字节 5 组全过 |
| 模糊测试 | ✅ | `test_fuzz_ha_json_scan.c`:**4948 万次运行零崩溃** |
| 接入 Go 生产路径 | ⏳ | **有意未做**:库先验完再换产线,接线是独立一步 |
### ★ 本刀被测试抓出的真实缺陷(7 个,全部记入代码注释防复发)
写 C 时**同一份逻辑我读了三遍都认为正确**,是测试把它们逐个揪出来的。
这正是「黄金对照 + 模糊测试」不可省的理由 —— 手写解析器的错不是崩溃,
而是**静默分叉**(少一个字符、某些块被丢弃),生产里极难归因。
| # | 缺陷 | 症状 | 谁抓到 |
|---|---|---|---|
| 1 | 代理对合成成功后**未跳过** unconditionally 的 U+FFFD 发射 | `\ud83d\ude00`(😀)→ 两个 U+FFFD | 契约测试 |
| 2 | 过长编码检查用了**只含首字节位**的 cp | `你`(e4 bd a0) → 6 个 U+FFFD | 契约测试 |
| 3 | `members_next` 只报值起点、**不消费值** | 游标停在值前 → 下个成员解析到上一个值 | **模糊测试第一轮** |
| 4 | 扫描阶段**不校验**转义字符合法性 | `{"a":"\q"}` C 判合法、`json.Valid`=false | 黄金对照 |
| 5 | 扫描阶段**不校验** `\u` 后四位十六进制 | `{"a":"\u00"}` 同上 | 黄金对照 |
| 6 | `get_int` **接受前导零** | `007`/`00` C 认、JSON 非法 | 黄金对照 |
| 7 | cgo 桥接把 C 结构体声明为 Go 局部变量 | `cgo argument has Go pointer to unpinned Go pointer` panic | Go 运行时 |
**另外纠正了我自己两次错误的「真值」**(比代码 bug 更危险,因为它会变成错误的规格):
- 第一版真值表里 `content:{}` 的花括号**少了一层**,于是把「我写错了 JSON」
误读成「Go 对 content 类型严格」。修正后实测:`content:{}` → `"{}"`(**宽松**)。
- 由此才看出一对**方向相反**的语义:`content` 走 `interface{}` **宽松**
(`{}`→`"{}"`、`true`→`"true"`、`1.5`→`"1.5"`),而 `reasoning_content` /
`usage` / `finish_reason` 是**强类型严格**(`123` ⇒ 整块作废)。
若照错误的表去写 C,会产出一个「比 Go 更严格」的实现,静默丢弃本该生效的块。
### 两个设计决定(来自缺陷 3、7)
1. **`members_next` 返回完整值 span 并内部跳过它**
—— 让「返回 1」蕴含「该成员良构」。要求调用方自己推进游标的 API 是错的:
忘一次就会解析到上一个值(缺陷 3),而这种错**不会报错**。
2. **`members_complete()` 区分「正常扫到 `}`」与「输入畸形」**
—— 复刻 Go 的严格性必须能分辨二者,否则畸形输入会被当正常结束。
### 刻意保留的能力(当前调用方用不到,但设计上不该省)
`\uXXXX` 解码(含代理对合成)。本次内核的 Go 基线里没有这种输入(实测确认),
但**上游网关的行为不由我们控制** —— 日志与已拦缺陷记录显示,网关确会发
`content` 为 JSON 字符串的形态。留着它是防止未来某条上游路径切到转义形态时,
内容**静默变成 `?0?d?d?0`**(那正是 SDK `ha_json.c` 的缺陷 1 的形态)。
代价是约 10 行代码 + 一组已通过的测试。
## 五、接线前探明的六个**语义**(决定「C 化到什么程度」)
第二刀把库验完后,接线前又探了一轮 wire 语义。其中两条**直接推翻了
「整条 parseOpenAICompatibleStreamChunkFull 全 C 化」的设想**。
### 5.1 重复键是**字段级合并**(`json.Unmarshal` 的数组语义)
```
{"choices":[{"delta":{"content":"a"}}],"choices":[{"delta":{"reasoning_content":"r"}}]}
→ content="a" reasoning="r" ← 两个都保留!
{"choices":[{"delta":{"content":"a"}}],"choices":[{"delta":{}}]}
→ content="a" ← 第二次是空 delta,也没把 content 清掉
{"usage":{"prompt_tokens":1},"usage":{"completion_tokens":2}}
→ usage={1,2,0} ← 字段级合并
```
**机制**:`d.saveError(&d.array)` 保存目标;`object()` 收尾时执行
`v.SetIndex(i, subv.v)`,而 subv 解析时拿到的是**已存在元素的指针**
⇒ 第二次 unmarshal 是**叠加**在第一次之上的,不是替换。
⇒ 「第二个 element 整体覆盖第一个」是**错的**。正确实现需要维护
**「本次哪些字段出现过」的** 逐字段表**。这能做,但要显式建模。
### 5.2 `stringifyContent` 的默认分支 = `json.Marshal(interface{})`(**再编码**)
这是最关键的一条。`content` 是 `interface{}`,落到 default 分支时
**重新序列化一遍**:
| content 输入 | stringifyContent 输出 |
|---|---|
| `{"b":1,"a":2}` | `{"a":2,"b":1}`(**键排序**) |
| `{"k":"<a>&b"}` | `{"k":"\u003ca\u003e\u0026b"}`(**HTML 转义**) |
| `1e2` | `100`(float64 归一) |
| `1.0` | `1` |
| `123456789012345678` | `123456789012345680`(float64 舍入) |
| `1e21` | `1e+21` |
要让 C 版与 Go 逐值一致,就必须复刻 Go 的:
① 浮点**最短往返**格式化(`strconv.AppendFloat` 的 Ryu 语义,位数随值变化)
② `map` **按键排序**(Go 的 map 无序 ⇒ 排序是 Marshal 的确定性来源)
③ 字符串的 **HTML 转义 + `
/
` 转义**
④ int → **float64 舍入**再格式化
这不是「顺手写一下」的量级,而是一整套序列化器 + 一个浮点格式化器。
### 5.3 结论:C 化**降级**为「结构导航」层,序列化留在 Go
本条不是为了少做事,而是因为上面两条决定了一个可检验的事实:
> **C 负责把 JSON 定位到「哪个值在哪里」(零分配、零解码);
> Go 负责把「已定位的原始字节」变成 `interface{}`(`json.Unmarshal`),
> 再按既有逻辑变成字符串。**
C 层因此**无需**理解重复键的合并语义(5.1)、**无需**实现浮点格式化
与键排序(5.2)—— 它只回答「`choices[0].delta.content` 的 span 在哪」。
代价与收益(如实记录):
| | 收益 | 代价 |
|---|---|---|
| C 定位 | 免除 `json.Unmarshal` 的**反射建树**(每块 12~21 allocs 的主因) | 命中字段仍要一次小 `Unmarshal` |
| 保留 Go 序列化 | 5.1/5.2 的语义**逐字**保持,不需要两套实现 | 值转换仍有少量 alloc |
**唯一例外(已实测可达)**:`arguments` 若 upstream 发的是**非字符串**
对象/数组,Go 侧会 `json.Marshal` 重新编码(`{"b":2,"a":1}` → `{"a":1,"b":2}`),
**重新编码的键序可能与原文不同**。这类值必须走 Go(见接线实现的注释)。
### 5.4 其余四条语义(接线时直接照做即可)
| # | 语义 | 实测 |
|---|---|---|
| 1 | **key 大小写敏感**(map key) | `{"TEXT":"up"}` 取不到 `text`;但 `{"CHOICES":[{"DELTA":{"CONTENT":"ci"}}]}` 有效(struct 字段名不敏感) |
| 2 | `content` 数组:非对象元素**静默跳过** | `["a",{"text":"b"}]` → `"b"` |
| 3 | `content` 数组:`text` 非字符串**静默跳过** | `[{"text":123},{"text":"b"}]` → `"b"` |
| 4 | `index` 非整数 ⇒ **整块作废** | `{"index":1.5}` → false |
| 5 | usage 的 cache 字段类型错也**让整块作废** | `{"prompt_cache_hit_tokens":"x","prompt_tokens":1}` → false |
第 1 条与 ha_json_scan 的 `ha_json_key_eq`(大小写不敏感)**语义相反**,
两者用途不同、互不冲突(见 §5.3)—— 但必须在代码里注明,否则后人会「统一」掉。
## 六、接线实测:**本架构比原实现慢**(诚实记录,已默认关闭)
第三刀把 `ha_json_scan` 接进了生产路径(`chunkParseFast`),
**6 万+ 差分用例证明它与原实现逐值等价**(含语法、成员、解码、整数、
随机 JSON、随机字节五组)。但基准给出了**否定结论**,故**默认关闭**。
### 6.1 实测对比(`codec_chunkfast_bench_test.go`,20000 次迭代)
| 场景 | 新路径(Entry) | 原实现(GoOnly) | 结论 |
|---|---:|---:|---|
| content_zh | 2245 ns / 20 allocs | 2038 ns / 13 allocs | 更慢 |
| content_ascii | **2016 ns / 20 allocs** | **1325 ns / 13 allocs** | 慢 52% |
| toolcall | **5854 ns / 33 allocs** | **3270 ns / 21 allocs** | 慢 79% |
| usage | 3170 ns / 24 allocs | 2832 ns / 12 allocs | 更慢 |
| finish | 1560 ns / 20 allocs | 1098 ns / 12 allocs | 更慢 |
分配数**也变多**(20 vs 13)—— 与本刀「消除 GC 抖动」的初衷相反。
### 6.2 根因(逐项测出来的,不是猜的)
| 测量 | 数值 | 含义 |
|---|---:|---|
| 裸 cgo 调用(无 out-param) | **168 ns** | 一次性边界成本 |
| 带 out-param 的键查找 | **205 ns / 2 allocs** | 边界 + out-param 逃逸到堆 |
| 一次解析需要的键查找次数 | **5+** | choices→[0]→delta→content/reasoning/tool_calls→finish_reason |
⇒ **5 × 205ns ≈ 1µs 的边界与分配成本,恰好把收益全部吃掉。**
而 Go 侧是**一次** `json.Unmarshal` 遍历建整棵树。
**根因一句话**:本架构是「用很多次廉价调用,换一次昂贵调用」——
在这个尺寸上不划算。逐字段往返是设计错误,不是实现调优能救的。
### 6.3 天花板实验:方向对,但当前实现没到
为判断「还值不值得改」,我测了一个假设性上界 —— **假设拿到 span 完全免费**
(span 预先算好),只测本设计中**必须由 Go 做**的那部分:
| | ns/op | allocs |
|---|---:|---:|
| 我设计里的 Go 侧工作(零边界成本) | **505** | **7** |
| 原实现(整块 json.Unmarshal) | 1239 | 13 |
⇒ 若边界成本能压到近零,**仍有 2.4× 时间与 46% 分配的空间**。
故这不是「C 化没意义」,而是「**逐字段往返**这个交互方式是错的」。
### 6.4 正确的下一步(已由实测指明)
改造方向不是调优现有代码,而是**减少跨界次数**:
1. **一次 C 调用返回全部字段的 span**(批量),而不是逐字段往返
—— 把 5+ 次边界压成 1 次
2. **结果写入调用方栈上的 C 结构体**,消除 out-param 逃逸(那 2 allocs)
3. 仅在 content/usage **确需重新编码**时回退 Go
### 6.5 为什么把「一个没有启用的优化」连代码一起提交
- **正确性基准**:6 万+ 差分用例已把 C 与 Go 的逐值等价钉死,
这是改造的**已验证起点**(field-locating 与全部回退判据都验证正确了)
- **一条永不静默回退的机制**:`TestChunkFast_BenchGate` 断言
`chunkFastEnabled` 必须为 `false`。后来者看到「快速路径写得挺全 +
差分测试全过」,很自然会以为它已生效并打开它 —— 而实测它更慢。
断言把这个事实钉住,改动即判红。
- **诚实**:不把「写了但没效果」包装成「已完成」。
> 教训(与第一刀同源):**「C 比 Go 快」不是前提,是待验证的假设。**
> 第一刀推翻过一次(`C.CString` 造成 82% 自找开销),这一刀又推翻一次
> (逐字段往返造成 5+ 次边界)。两次都是**测量**推翻了直觉。
## 七、第三刀返工:批量定位(把 5+ 次边界压成 1 次)—— **部分成功,仍默认关闭**
§六 的否定结论指出根因是「逐字段往返」。本节按 §6.4 做架构改造并重测。
### 7.1 改造内容
| 项 | 改造前 | 改造后 |
|---|---|---|
| cgo 边界次数 | **5+**(每字段一次 findKey) | **1**(`ha_sse_chunk_locate`) |
| 键查找方式 | 每个键各扫一遍对象(6 趟) | **单趟分派**(遍历成员表一次就分发) |
| 解码 | 每字段一次往返 + 各自 decBuf | 同一趟内解码进**一块** sbuf(1 次分配) |
| 成员表遍历 | 6 趟 | **2 趟**(顶层 + delta) |
顺带修掉两处自己造的浪费(都是「先扫一遍拿个数、再扫第二遍拿首元素」):
`choices` 数组的「数个数 + 取首元素」合一趟;`choice0` 内的
delta/finish_reason 合一趟。
### 7.2 实测(50000 次迭代 × 3 轮,取中位;`benchtime` 与机器同前)
| 场景 | 改造后 Entry | 原实现 GoOnly | 判定 |
|---|---:|---:|---|
| content_zh | **1540** ns / 5 allocs | 1871 ns / 13 allocs | ✅ **快 18%**,分配 -62% |
| content_ascii | 1250 ns / 5 allocs | 1304 ns / 13 allocs | ⚠ 持平,分配 -62% |
| finish | **820** ns / 6 allocs | 921 ns / 12 allocs | ✅ 快 11% |
| usage | 2530 ns / 9 allocs | 2591 ns / 12 allocs | ✅ 持平偏快 |
| toolcall | **3450** ns / 20 allocs | **3000** ns / 21 allocs | ❌ **慢 15%** |
**从「五项全输」变成「三项赢 / 一项持平 / 一项输」**,且**所有场景的
分配数都下降**(13→5、12→6、12→9)。
### 7.3 为什么 `toolcall` 仍输(根因已定位)
分解测量:
| 组成 | 成本 |
|---|---:|
| C 侧一次 `ha_sse_chunk_locate`(纯 C,零边界零分配) | **766 ns** |
| Go 侧 `[]openAIToolCall` unmarshal | **1305 ns / 15 allocs** |
| 对照:Go 整块 unmarshal(一次搞定) | ~2980 ns |
问题在第二行:tool_calls 的元素是**对象**,`Arguments interface{}` 需要
真实的 `map[string]interface{}`,所以**必须**走 encoding/json 的反射建树。
而我们为了定位又先做了一遍 C 扫描 —— 于是「扫两遍」必然慢于「扫一遍」。
⇒ **这不是 C 慢,是「同一份数据被解析了两次」**:
C 负责定位(读一遍),encoding/json 负责建树(再读一遍)。
对**标量**字段(content / reasoning / finish)C 能一次到位,所以那些场景赢;
对**需要建树**的字段(tool_calls / usage)C 的定位是纯开销。
**解法(下一步)**:tool_calls / usage 命中时**完全跳过 C 定位**,
直接让 encoding/json 整块处理 —— 也就是「**按字段类型决定要不要 C 化**」。
这需要一次「试解析」来判断字段是否需要建树,或改为「先看顶层键集合再决策」。
### 7.4 当前状态:仍默认关闭
「五项全输」→「三项赢一项输」,不足以打开默认开关,理由:
1. **toolcall 是真实负载里最常见的一类块**(任何一次工具调用流),
而它仍慢 15%。在真实会话里,工具调用往往比纯文本多。
2. **收益幅度不足以抵消风险**:18% 的时间收益 vs 引入一层
与 `encoding/json` 语义并存的第二实现。而 tool_calls 路径的
分配数几乎没降(20 vs 21)—— 本刀的原始动机(消除 GC 抖动)
在最需要它的场景**没有兑现**。
⇒ 继续做的前提是**先把 §7.3 的解法做掉**(按字段类型决定是否 C 化),
让 toolcall 也不输,再重测。届时再决定是否开启。
### 已知边界(诚实记录)
- `ha_json_get_int` 返回 `long long`;Go 侧 usage 字段是 `int`(64 位平台相同,
32 位平台需截断检查)。当前未做平台相关处理 —— 内核只发布 linux/amd64 与
linux/arm64(均 64 位),故暂不构成问题,但若将来上 32 位需补。
- 契约测试里 `check_str` 的重载写法偏笨拙(C 无重载),但已够用。
> ⚠️ 纪律:与第一刀同 —— **C 与纯 Go 逐值等价由黄金对照测试钉死**,
> 且**不做按长度分派**(两条语义可能分叉的实现绝不允许同时在产线)。