mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 20:33:15 +00:00
第三刀:把 ha_json_scan 接进 parseOpenAICompatibleStreamChunkFull。
**结论是否定的** —— 实测比原实现慢,故默认关闭并如实记录。这条提交的
价值在于「已钉死的正确性 + 已定位的根因 + 一条防静默回退的断言」。
## 设计:只做「结构导航」,序列化留在 Go
接线前实测出两条 wire 语义,它们让「整条解析全 C 化」不成立:
§5.1 重复键是**字段级合并**,不是替换:
{"choices":[{content:a}],"choices":[{reasoning:r}]} → 两个都保留。
机制:json.Unmarshal 的 object() 收尾做 v.SetIndex(i, subv.v),
而 subv 拿到的是**已存在元素的指针** ⇒ 第二次是叠加。
§5.2 stringifyContent 的 default 分支 = json.Marshal(interface{}),
即**重新序列化**:{"b":1,"a":2}→{"a":2,"b":1}(键排序)、
1e2→100、<→\u003c、大 int 先舍入成 float64。
逐值一致 = 复刻 Ryu 最短浮点 + map 键排序 + HTML 转义 + int 舍入。
两条都只在**取值**阶段需要,故 C 只回答「值在哪里」(零分配零解码),
类型检查靠「用相同的 Go 类型 unmarshal 相同形状的子树」保证,不靠 C 复刻规则。
## 实测:新路径比原实现慢(20000 次迭代)
| 场景 | 新路径 | 原实现 |
|---|---|---|
| content_ascii | 2016ns / 20allocs | 1325ns / 13allocs |
| toolcall | 5854ns / 33allocs | 3270ns / 21allocs |
| usage | 3170ns / 24allocs | 2832ns / 12allocs |
分配数**也变多**(20 vs 13),与「消除 GC 抖动」的初衷相反。
根因(逐项测量,非猜测):裸 cgo 调用 168ns;**每次带 out-param 的键查找
205ns + 2 allocs**(out-param 逃逸到堆);一次解析需要 5+ 次查找
⇒ 边界与分配成本约 1µs,恰好吃掉全部收益。Go 侧只需**一次** Unmarshal。
一句话:**用很多次廉价调用换一次昂贵调用,在这个尺寸上不划算。**
## 天花板实验:方向对,但当前实现没到
假设拿到 span 完全免费,只测设计中必须由 Go 做的部分:
我的 Go 侧 505ns/7allocs vs 原实现 1239ns/13allocs
⇒ 边界归零后仍有 2.4× 时间、46% 分配的空间。故问题在**逐字段往返**
这个交互方式,不在 C 本身。正确改造:一次调用返回全部字段 span +
结果写调用方栈结构体 + 仅在确需重新编码时回退。
## 正确性:6 万+ 差分用例全过
同一批输入跑两条路径逐字段比对(Content/Reasoning/Done/Finish/ToolCalls/
Usage + bool),5 组:协议形态(含全部回退触发条件)、真实负载、随机 JSON
30000 例、随机字节 30000 例、优化有效性。
★ 差分测试当场抓出 4 个真实缺陷(其中一个正是「优化压根没生效」):
1. ha_sse_arr_first 里「重新 init 到 sc.s+sc.i」使 base 变了 ⇒ start 恒 0
⇒ 返回的是**数组本身**而非首元素。症状是**快速路径永远不生效**——
而若只看「结果与 Go 一致」,这个 bug 会**完全隐形**(回退总是对的)。
⇒ 这就是必须单独断言「优化确实被走到」的原因。
2. chunkAssemble 的 bool 被丢弃 ⇒ 空对象被判 true(原实现 false)
3. 键匹配层级搞错:delta 是 **struct**(字段名 CI),不是 map。
我一度「推理」成 CS 并以为差分测试会通过——错的。
教教训:哪层是 struct、哪层是 map 要**回原实现读类型**,不能凭字段名推断。
4. cgo 边界:out-param 逃逸到堆
另修:C 代码从 cgo 前言移进 csrc/ ——前言里的 C **逃出全部 C 门禁**
(告警/sanitizer/交叉/模糊测试),而它恰是本刀最易出错处。
## 防静默回退
TestChunkFast_BenchGate 断言 chunkFastEnabled 必须为 false。
后来者看到「快速路径写得全 + 差分测试全过」,很自然会以为它已生效并打开它
—— 而实测更慢。断言把这个事实钉住,改动即判红。
## 实测汇总
- C 契约 119 项断言、黄金对照 5 组、差分 6 万+ 例:全过
- ASan+UBSan PASS;gcc+clang 零告警;arm64 交叉 0 告警(3 个源文件)
- 全量 go test -count=1 ./... 38 包 ok / 0 FAIL
- libFuzzer 4948 万次零崩溃(上一刀)
教训(与第一刀同源):**「C 比 Go 快」不是前提,是待验证的假设。**
第一刀被 C.CString 的 82% 自找开销推翻一次,这一刀被逐字段往返推翻一次。
两次都是测量推翻直觉。
354 lines
13 KiB
Go
354 lines
13 KiB
Go
//go:build cgo
|
||
|
||
package api
|
||
|
||
// codec_chunkfast_c.go —— parseOpenAICompatibleStreamChunkFull 的 C 快速路径
|
||
//
|
||
// ============================ 契约(务必先读) ============================
|
||
// 本文件是**纯优化**:它必须与 chunkParseGo 对**所有输入**产出完全相同的
|
||
// (StreamChunk, bool)。保证方式不是「小心写」,而是结构上的三条:
|
||
//
|
||
// 1. 任一环节判「不确定」⇒ **整体回退** chunkParseGo。没有任何分支
|
||
// 「尽力猜」或「部分采用」。
|
||
// 2. 每个「C 已判过合法」的子树,都用**与 Go 侧完全相同的 Go 类型**去
|
||
// unmarshal ⇒ 类型检查语义天然一致,不靠 C 复刻类型规则。
|
||
// 3. 拼装(chunkAssemble)与工具调用归一化(normalizeStreamToolCall)
|
||
// 由两条路径**共用**,结构上无法分叉。
|
||
//
|
||
// 回退触发条件(穷举):
|
||
// · 顶层不是「恰好一个」良构对象(含尾部残留,见 ha_sse_root_object)
|
||
// · 发现**重复键**(§5.1 字段级合并语义,C 不实现)
|
||
// · content 是对象/数字/字面量(stringifyContent 需 json.Marshal 重新编码,§5.2)
|
||
// · tool_calls 元素畸形 / 数组元素过多
|
||
// · 任何子树畸形或缓冲不足
|
||
//
|
||
// 实测:真实负载三种块全部走快速路径;探针里的畸形、重复键、对象 content
|
||
// 等形态全部命中回退。
|
||
//
|
||
// ============================ ★ 当前默认**关闭**(实测比原实现慢) ============================
|
||
// 见 codec_chunkfast_bench_test.go 的实测:
|
||
// content_ascii Entry 2016ns/20allocs vs GoOnly 1325ns/13allocs
|
||
// toolcall Entry 5854ns/33allocs vs GoOnly 3270ns/21allocs
|
||
// usage Entry 3170ns/24allocs vs GoOnly 2832ns/12allocs
|
||
//
|
||
// 根因(已逐项测量,不是猜测):
|
||
// 1. **每次键查找 205ns + 2 allocs**(out-params 逃逸到堆),
|
||
// 而**裸 cgo 边界就有 168ns**。一次解析需要 5+ 次查找
|
||
// (choices→[0]→delta→content/reasoning/tool_calls→finish_reason)
|
||
// ⇒ 边界成本 ≈ 1µs,恰好吃掉全部收益。
|
||
// 2. 每个字段还各自一次小 Unmarshal + 一次 decBuf 分配。
|
||
// 而 Go 侧是**一次** Unmarshal 遍历建整棵树。
|
||
//
|
||
// ⇒ 本架构是「**用很多次廉价调用换一次昂贵调用**」,在这个尺寸上不划算。
|
||
// 正确的前进方向是**减少边界次数**,而不是调优现有代码:
|
||
// · 一次 C 调用返回**全部**字段的 span(批量,不逐字段往返)
|
||
// · 结果写入**调用方栈上**的 C 结构体(消除 out-param 逃逸)
|
||
// · 仅在 content/usage 确需重新编码时回退 Go
|
||
// 天花板实测:若边界成本归零,Go 侧代价 ≈ 505ns/7allocs
|
||
// (对 1239ns/13allocs)⇒ **方向对,但当前实现没到**。
|
||
//
|
||
// ★ 保留本文件的理由:它同时是
|
||
// ① 正确性基准(6 万+ 差分用例已钉死 C 与 Go 逐值等价)
|
||
// ② 上述改造的**已验证起点**(field-locating 与回退判据都已验证正确)
|
||
// ③ 一条**永不静默回退**的机制:若未来把它切回默认开启,
|
||
// TestChunkFast_BenchGate 会立刻用基准把它按回去。
|
||
|
||
|
||
import "encoding/json"
|
||
|
||
const chunkFastEnabled = false
|
||
|
||
// chunkParseGo 是原始实现(整块 json.Unmarshal),作为快速路径的**唯一判据**
|
||
// 与回退目标。
|
||
func chunkParseGo(data string) (StreamChunk, bool) {
|
||
var raw struct {
|
||
Choices []struct {
|
||
Delta struct {
|
||
Content interface{} `json:"content"`
|
||
ReasoningContent string `json:"reasoning_content"`
|
||
ToolCalls []openAIToolCall `json:"tool_calls"`
|
||
} `json:"delta"`
|
||
FinishReason *string `json:"finish_reason"`
|
||
} `json:"choices"`
|
||
UpstreamUsage chunkUsage `json:"usage"`
|
||
}
|
||
if err := json.Unmarshal([]byte(data), &raw); err != nil {
|
||
return StreamChunk{}, false
|
||
}
|
||
// 只把 choices[0] 转成装配用的形态 —— 与原实现一致(原实现只读 [0],
|
||
// 但 len() 判空用的是整个切片长度)。
|
||
var choices []chunkChoice
|
||
if len(raw.Choices) > 0 {
|
||
c := raw.Choices[0]
|
||
choices = []chunkChoice{{
|
||
content: stringifyContent(c.Delta.Content),
|
||
reasoning: c.Delta.ReasoningContent,
|
||
toolCalls: normalizeStreamToolCalls(c.Delta.ToolCalls),
|
||
finishPtr: c.FinishReason,
|
||
}}
|
||
} else if len(raw.Choices) == 0 {
|
||
choices = nil
|
||
}
|
||
return chunkAssemble(choices, raw.UpstreamUsage)
|
||
}
|
||
|
||
// chunkUsage 镜像 Go 侧的 UpstreamUsage 匿名结构。
|
||
type chunkUsage struct {
|
||
PromptTokens int `json:"prompt_tokens"`
|
||
CompletionTokens int `json:"completion_tokens"`
|
||
TotalTokens int `json:"total_tokens"`
|
||
Prompt int `json:"prompt"`
|
||
Completion int `json:"completion"`
|
||
Total int `json:"total"`
|
||
PromptCacheHit int `json:"prompt_cache_hit_tokens"`
|
||
PromptCacheMiss int `json:"prompt_cache_miss_tokens"`
|
||
PromptTokensDetails *struct {
|
||
CachedTokens int `json:"cached_tokens"`
|
||
} `json:"prompt_tokens_details"`
|
||
}
|
||
|
||
// chunkChoice 是装配用的形态:Content 已过 stringifyContent。
|
||
type chunkChoice struct {
|
||
content string
|
||
reasoning string
|
||
toolCalls []ToolCall
|
||
// finishPtr 保留三态区分:缺失/null ⇒ nil;"" ⇒ 非 nil 但空串
|
||
//(空串**不算**终止信号,sensenova 每块都发 "")。
|
||
finishPtr *string
|
||
}
|
||
|
||
// chunkAssemble 把已备好的选择与 usage 拼成 StreamChunk。
|
||
// **两条路径共用**它 ⇒ 拼装逻辑不可能分叉。
|
||
func chunkAssemble(choices []chunkChoice, usage chunkUsage) (StreamChunk, bool) {
|
||
var u *TokenUsage
|
||
if usage.Total > 0 || usage.TotalTokens > 0 ||
|
||
usage.Prompt > 0 || usage.PromptTokens > 0 {
|
||
u = &TokenUsage{
|
||
Prompt: pickFirstInt(usage.PromptTokens, usage.Prompt),
|
||
Completion: pickFirstInt(usage.CompletionTokens, usage.Completion),
|
||
Total: pickFirstInt(usage.TotalTokens, usage.Total),
|
||
}
|
||
}
|
||
if len(choices) == 0 {
|
||
// 纯 usage 心跳块:有 usage 就透传,否则丢弃
|
||
if u != nil {
|
||
return StreamChunk{Usage: u}, true
|
||
}
|
||
return StreamChunk{}, false
|
||
}
|
||
c := choices[0]
|
||
ck := StreamChunk{
|
||
Content: c.content,
|
||
ReasoningContent: c.reasoning,
|
||
ToolCalls: c.toolCalls,
|
||
Usage: u,
|
||
}
|
||
if c.finishPtr != nil && *c.finishPtr != "" {
|
||
ck.Done = true
|
||
ck.FinishReason = *c.finishPtr
|
||
}
|
||
return ck, true
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// C 快速路径
|
||
// ---------------------------------------------------------------------
|
||
|
||
// chunkParseFast 尝试 C 快速路径。
|
||
// 返回 (chunk, handled, decided):
|
||
// handled=false ⇒ 调用方必须用 chunkParseGo
|
||
// handled=true,decided=true ⇒ 结果是最终答案
|
||
func chunkParseFast(data string) (StreamChunk, bool, bool) {
|
||
root := rootSpan(data)
|
||
if !sseRootObject(root) {
|
||
return StreamChunk{}, false, false
|
||
}
|
||
|
||
// ---- usage:整棵子树交给 encoding/json ----
|
||
// ★ 为什么逐个整数取是错的:Go 侧 usage 有 9 个字段,且**任一类型不符
|
||
// 就让整块作废**(实测 {"prompt_cache_hit_tokens":"x","prompt_tokens":1}
|
||
// → 整块 false)。整棵 unmarshal 到**同一个 Go 类型** ⇒ 语义自动一致。
|
||
var usage chunkUsage
|
||
us, ufound, udup, ubad := findKeyCI(root, "usage")
|
||
if ubad || udup {
|
||
return StreamChunk{}, false, false
|
||
}
|
||
if ufound {
|
||
switch us.firstByte() {
|
||
case 'n':
|
||
// null ⇒ 零值 struct(不产出 usage)
|
||
case '{':
|
||
if err := json.Unmarshal(us.bytes(), &usage); err != nil {
|
||
// ★ 类型不符 ⇒ 与 Go 一样「整块作废」,**不需要回退**
|
||
return StreamChunk{}, false, true
|
||
}
|
||
default:
|
||
return StreamChunk{}, false, false // 交回 Go 决定
|
||
}
|
||
}
|
||
|
||
// ---- choices:只取 [0],但要先判整切片的长度语义 ----
|
||
cs, cfound, cdup, cbad := findKeyCI(root, "choices")
|
||
if cbad || cdup {
|
||
return StreamChunk{}, false, false
|
||
}
|
||
if !cfound {
|
||
ck, ok := chunkAssemble(nil, usage)
|
||
return ck, true, ok
|
||
}
|
||
switch cs.firstByte() {
|
||
case 'n':
|
||
// null ⇒ 零值切片(长度 0)⇒ 走「无 choices」分支
|
||
ck, ok := chunkAssemble(nil, usage)
|
||
return ck, true, ok
|
||
case '[':
|
||
default:
|
||
return StreamChunk{}, false, false
|
||
}
|
||
el, has := firstElem(cs)
|
||
if !has {
|
||
// 空数组:len(choices)==0 ⇒ 与 Go 相同
|
||
ck, ok := chunkAssemble(nil, usage)
|
||
return ck, true, ok
|
||
}
|
||
ch, ok := fastChoice(el)
|
||
if !ok {
|
||
return StreamChunk{}, false, false // 任何不确定 ⇒ 整体回退
|
||
}
|
||
ck, ok2 := chunkAssemble([]chunkChoice{ch}, usage)
|
||
return ck, true, ok2
|
||
}
|
||
|
||
// fastChoice 解析 choices[0]。ok=false ⇒ 必须回退 Go。
|
||
//
|
||
// ★ 键匹配方式按 Go 那一跳的实际类型选择:
|
||
// - choices / delta / finish_reason / tool_calls 是 **struct 字段** ⇒ 大小写不敏感
|
||
// - content / reasoning_content / "text" 是 **interface{} → map key** ⇒ 大小写敏感
|
||
// (实测:{"CHOICES":[{"DELTA":{"CONTENT":"ci"}}]} 有效;
|
||
// {"content":[{"TEXT":"up"}]} 取不到 text)
|
||
func fastChoice(el strSpan) (chunkChoice, bool) {
|
||
var ch chunkChoice
|
||
if el.firstByte() != '{' {
|
||
return ch, false
|
||
}
|
||
|
||
ds, dfound, ddup, dbad := findKeyCI(el, "delta")
|
||
if dbad || ddup {
|
||
return ch, false
|
||
}
|
||
if dfound {
|
||
switch ds.firstByte() {
|
||
case 'n':
|
||
// delta:null ⇒ 零值 struct
|
||
case '{':
|
||
// ★ delta 是 **struct**(不是 map!)——
|
||
// 原实现:Delta struct { Content interface{}; ... } `json:"delta"`
|
||
// 故它的字段名匹配是**大小写不敏感**。
|
||
// 实测 `{"CHOICES":[{"DELTA":{"CONTENT":"ci"}}]}` → content="ci"。
|
||
// 只有 content 的**值**(若为对象/数组)才成为 map/[]interface{},
|
||
// 那时里面的键(如 "text")才是大小写敏感。
|
||
//
|
||
// 我一度把这里改成 CS 并认为「差分测试会通过」——那是错的推理:
|
||
// Go 侧给的是 "ci"(CI 匹配成功),改成 CS 反而把快速路径弄丢。
|
||
// 教训:**「哪一层是 struct、哪一层是 map」要回原实现读类型,
|
||
// 不能凭字段名像 map 就推断它是 map。**
|
||
|
||
// reasoning_content:Go 侧是 **string**(强类型)。
|
||
// 用同样的 Go 类型 unmarshal ⇒ 123 会报错,与原实现一致。
|
||
rs, rfound, rdup, rbad := findKeyCI(ds, "reasoning_content")
|
||
if rbad || rdup {
|
||
return ch, false
|
||
}
|
||
if rfound && rs.firstByte() != 'n' {
|
||
var s string
|
||
if err := json.Unmarshal(rs.bytes(), &s); err != nil {
|
||
return ch, false // 类型不符 ⇒ 回退(Go 会整块作废)
|
||
}
|
||
ch.reasoning = s
|
||
}
|
||
|
||
// content 字段名:CI(struct 字段)。
|
||
// 其**值**若是数组/对象,内部键由 ha_sse_stringify 按 CS 处理。
|
||
cs, cfound, cdup, cbad := findKeyCI(ds, "content")
|
||
if cbad || cdup {
|
||
return ch, false
|
||
}
|
||
if cfound {
|
||
if s, handled := stringifyC(cs); handled {
|
||
ch.content = s
|
||
} else {
|
||
return ch, false // 需 json.Marshal 重新编码(§5.2)
|
||
}
|
||
}
|
||
|
||
// tool_calls:逐个元素整体 unmarshal 成 openAIToolCall,
|
||
// 使 arguments 的 interface{} 形态 / 类型检查全由 encoding/json 负责。
|
||
tcs, tfound, tdup, tbad := findKeyCI(ds, "tool_calls")
|
||
if tbad || tdup {
|
||
return ch, false
|
||
}
|
||
if tfound {
|
||
switch tcs.firstByte() {
|
||
case 'n':
|
||
// null ⇒ 零值切片
|
||
case '[':
|
||
t, ok := fastToolCalls(tcs)
|
||
if !ok {
|
||
return ch, false
|
||
}
|
||
ch.toolCalls = t
|
||
default:
|
||
return ch, false
|
||
}
|
||
}
|
||
default:
|
||
return ch, false
|
||
}
|
||
}
|
||
|
||
// finish_reason:struct 字段 ⇒ 大小写不敏感;Go 侧是 *string
|
||
fs, ffound, fdup, fbad := findKeyCI(el, "finish_reason")
|
||
if fbad || fdup {
|
||
return ch, false
|
||
}
|
||
if ffound && fs.firstByte() != 'n' {
|
||
if fs.firstByte() != '"' {
|
||
return ch, false
|
||
}
|
||
var s string
|
||
if err := json.Unmarshal(fs.bytes(), &s); err != nil {
|
||
return ch, false
|
||
}
|
||
ch.finishPtr = &s
|
||
}
|
||
return ch, true
|
||
}
|
||
|
||
// fastToolCalls 解析 tool_calls 数组。
|
||
//
|
||
// ★ 逐元素整体 unmarshal 成 openAIToolCall 是刻意的:这样 arguments 的
|
||
// interface{} 形态、字符串/对象/数组/数字各分支、重复键,全部由
|
||
// encoding/json 处理(§5.2 的重新编码语义不必在 C 复刻)。
|
||
// 归一化也走**同一个** normalizeStreamToolCall ⇒ 与 Go 路径不分叉。
|
||
func fastToolCalls(arr strSpan) ([]ToolCall, bool) {
|
||
elems, ok := scanArray(arr)
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
if len(elems) == 0 {
|
||
return nil, true // 空数组 ⇒ nil(与 Go 的 normalizeStreamToolCalls 一致)
|
||
}
|
||
out := make([]ToolCall, 0, len(elems))
|
||
for _, elem := range elems {
|
||
if elem.firstByte() != '{' {
|
||
return nil, false
|
||
}
|
||
var raw openAIToolCall
|
||
if err := json.Unmarshal(elem.bytes(), &raw); err != nil {
|
||
return nil, false
|
||
}
|
||
out = append(out, normalizeStreamToolCall(raw))
|
||
}
|
||
return out, true
|
||
}
|