按 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
22 KiB
内核 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 透传。
三、不可协商的约束
- 只吃
(const char*, size_t),不要求 NUL 结尾(否则又是strlen+ 拷贝的 老问题,见第一刀 §7.1 的 82% 自找开销教训) - 不 malloc:结果用 span(指针+长度) 回给调用方,Go 侧零拷贝切片
- 无状态纯函数、线程安全
- 顶层:
ha_json_scan_chunks出 (span × N, 浅扫,含字符串内的{/}, 让顶层逗号分隔可被切分——这正是协议层的需要:内容里的逗号不该错切顶层) - 语法必须与
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)
members_next返回完整值 span 并内部跳过它 —— 让「返回 1」蕴含「该成员良构」。要求调用方自己推进游标的 API 是错的: 忘一次就会解析到上一个值(缺陷 3),而这种错不会报错。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 正确的下一步(已由实测指明)
改造方向不是调优现有代码,而是减少跨界次数:
- 一次 C 调用返回全部字段的 span(批量),而不是逐字段往返 —— 把 5+ 次边界压成 1 次
- 结果写入调用方栈上的 C 结构体,消除 out-param 逃逸(那 2 allocs)
- 仅在 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 当前状态:仍默认关闭
「五项全输」→「三项赢一项输」,不足以打开默认开关,理由:
- toolcall 是真实负载里最常见的一类块(任何一次工具调用流), 而它仍慢 15%。在真实会话里,工具调用往往比纯文本多。
- 收益幅度不足以抵消风险: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 逐值等价由黄金对照测试钉死, 且不做按长度分派(两条语义可能分叉的实现绝不允许同时在产线)。