Files
HomeAgent/docs/zh/c-core/llm-orchestration-c.md
JianFeeeee 5ccdf24f18 test(c-core): 补跨语言开销基线 —— 数据反驳「C 比 Go 快」的直觉
codec.go 的注释写着「EstimateTokens 是否该留在 C 侧由 codec_bench_test.go
的实测数据决定,不要凭直觉断言」,但那个文件此前并不存在(悬空引用)。
plan.md 与设计文档也都在说「需先有真实延迟基线(当前没有)」。本提交把它补上。

## 数据(ns/op,benchmem)

| 基准 | C(经 cgo)| 纯 Go | 谁快 |
|---|---:|---:|---|
| ModelContextWindow(短 ASCII)| 175 | 38 | Go 快 4.6× |
| EstimateTokens / 空串 | 100 | 0.43 | Go 快 230× |
| EstimateTokens / 短 ASCII | 115 | 6.5 | Go 快 17× |
| EstimateTokens / 短中文 | 100 | 29 | Go 快 3.4× |
| EstimateTokens / 中 200 字 | 229 | 509 | C 快 2.2× |
| EstimateTokens / 1KB 中文 | 840 | 2870 | C 快 3.4× |
| EstimateTokens / 1KB ASCII | 2318 | 332 | Go 快 7× |
| TruncateByTokens / 短中文 | 233 | 54 | Go 快 4.3× |
| TruncateByTokens / 1KB 中文 | 3923 | 6918 | C 快 1.8× |

(已用 -count 复测确认稳定;ascii_1k 的异常已单独隔离复测 3 次)

## 三条结论

1. **cgo 固定开销约 95–100 ns/次**,小输入下完全压倒算法差异。
2. C 只在**长中文**(UTF-8 步进重)上领先;长 ASCII 反而 Go 快 7×
   (Go 的 utf8.RuneCountInString 对 ASCII 有快路径,C 侧逐字节跑)。
3. ⇒ 判据应是「哪个在**真实输入分布**下真能变快」,不是「哪个看起来更底层」。

## 对后续 C 化的影响(已写进 plan.md §七 与设计文档 §7.1)

- EstimateTokens 的真高频点在 process.go:476 的逐事件循环与 resident.go:552。
  字段分布不单一:Source 是短标签(Go 快 17× 那一档),
  Input/Response 是对话文本(长中文 C 快、短文本与长 ASCII Go 快)。
  ⇒ 当前一刀切走 C 会让短串净亏;正确做法是按长度分派,
  但须先用真实长度分布复测,不要凭推测动手。
- ModelContextWindow(provider.go:333)与 TruncateByTokens(tooldefs.go:38)
  调用点单一、非热路径,开销在单次请求尺度上无关痛痒。

这不否定 C 化方向:协议编解码(JSON 解析、SSE 分片)处理长文本,
才是 C 的主场,也是比「把短函数搬过去」更合理的下一步。
2026-09-25 14:51:10 +08:00

17 KiB
Raw Blame History

内核 C 化 · 第一刀:LLM 编排层

分支:feature/c-core(从 main 拉出,遵循 git-release-discipline) 状态:第一刀已落地并闭环(2026-09-25)。L1 纯函数层的三个函数已在 C 侧 实现,双路径(cgo / 纯 Go 回退)与黄金对照测试均已在仓库内跑通; 构建链已打通至 linux/amd64 与 linux/arm64 两个真实发布目标。 本文保留设计与可行性论证,并在每节标注落地后的实际情况。 本文只写经实测确认的结论;每个「可行」都附验证方式,每个「不可行」都附证据。


一、为什么要 C 化,以及为什么先动 LLM 编排

内核当前是纯 Go 单进程(homed),插件经子进程 + 共享内存与之通信。 C 化的目标不是「换语言重写」,而是把稳定、高频、无 GC 抖动敏感的热路径 下沉为可复用 C 库,让内核在保持 Go 编排能力的同时获得:

  • 可预测的延迟(无 GC STW 影响热路径)
  • 可跨端复用(鸿蒙 / 嵌入式 / C SDK 侧同一份实现)
  • 与既有 C 资产统一(见 §三)

为什么第一刀是 LLM 编排:这一层是内核最核心的职责(assets/docs/zh/OVERVIEW.md 定义内核 = 「LLM 编排 + 记忆管理 + 知识检索」),且它天然分层—— 上层是有状态的调度/工具循环,下层是无状态的协议编解码。 后者是纯字符串进、结构体出,最适合先下沉。


二、可行性核实结论(实测)

2.1 工具链齐备

cc/gcc/clang:  /usr/bin/{cc,gcc,clang}
make/cmake:    /usr/bin/{make,cmake}
go env:        CC=gcc  CGO_ENABLED=1  GOOS=linux  GOARCH=amd64

2.2 已有纯 C 先例,可直接复用

third_party/homeagent-sdk/remotedevice/ 是一个零外部依赖的纯 C 库 (1320 行),已含可复用组件:

文件 行数 能力
src/ha_json.c 368 DOM 风格 JSON 解析器 + 流式构建器
src/ha_ws.c 324 WebSocket 客户端握手/帧
src/ha_remotedevice.c 628 设备通道
CMakeLists.txt — 静态/动态库、install 规则、可选测试

这意味着 JSON 解析这一 C 化的最大依赖,仓库里已有现成实现, 不需要引入 cJSON 等外部依赖,与「内核不新增外部依赖」的克制一致。

2.3 关键约束:Windows 构建是 CGO_ENABLED=0 → 前提已消失,改用另一条约束

⚠️ 本节结论已因「放弃 Windows 原生」而过时(见 §2.5)。 关键的一点在于,原论证推理的根基“Windows 包需要 CGO_ENABLED=0”已不成立, 故「必须保留纯 Go 回退」这个推论也不再由它支撑。 现在的实际策论是:用 cgo / !cgo 一组约束(回退实现保留,但理由是 waiter 等 CGO-free 目标与无 cgo 工具链场景,而不是 Windows), 而不需要额外的 hacodec tag ——因为 ha_codec 是零依赖纯 C99 源码 内联编译,不像 onnxruntime 那样需要运行期 .so。

原论证(保留作为推理参考):

deploy/packaging/package-windows.sh:69
  GOOS=windows GOARCH="$ARCH" CGO_ENABLED=0 \

这是 C 化最大的部署风险:若把编排逻辑改成必须 cgo,Windows 包直接编不出来。

已验证的解法:build-tag 双实现。实测(最小复现):

//go:build cgo
// a_cgo.go —— cgo 实现,链接 C 库

//go:build !cgo
// a_pure.go —— 纯 Go 回退实现,行为等价
CGO_ENABLED=1 go build ./...  → exit 0
CGO_ENABLED=0 go build ./...  → exit 0

结论:C 化必须始终保留纯 Go 回退路径,且两条路径要有同一组测试钉死 行为等价(黄金对照,见 §五)。这不是可选项——是 Windows 分发的前提。

2.4 落地后的形状修正(实测补充,2026-09-25)

实际落地时,三处原设计需要修正:

① 不链接静态库,也不 #include 包外源 —— 用包内符号链接。 原方案(LDFLAGS 指向 csrc/build/libha_codec.a)实测会造成两个必然失败:

  • .a 是构建产物、不入库(.gitignore 的 build/ 命中 csrc/build/), 而发布脚本原先并不产出它 ⇒ 「不入库 + 不生成」两头空,链接报 cannot find .../libha_codec.a。
  • 交叉编译 linux/arm64(homed 的真实发布目标)时,宿主 x86-64 的 .a 被链进目标产物,报 file in wrong format。

改为包内 ha_codec.c / ha_codec.h 符号链接到 csrc/ 权威源:

internal/agent/api/ha_codec.c -> ../../../csrc/src/ha_codec.c
internal/agent/api/ha_codec.h -> ../../../csrc/include/ha_codec.h

★ 为什么不能用 #include "../../../csrc/src/ha_codec.c"(包外相对包含): Go 构建缓存不跟踪包外被 #include 的 C 文件。实测:在包外源里把返回值从 7 改成 8,go test 依然通过(缓存命中、静默沿用旧代码);同样改动落在包内文件时 立即判红。对「逐步推进 C 化」这是致命的——改 C 源码却不生效且无任何报错。 (包内 shim #include 包外源同样漏跟踪,已实测排除。)

符号链接同时满足两点:文件在包目录内 ⇒ 缓存按内容正确跟踪; 只有一份权威源 ⇒ 无副本漂移、无需同步目标。

② csrc/CMakeLists.txt 的定位变化:不再是 Go 构建的前置,而是 C 侧独立复用(鸿蒙/嵌入式/C SDK)与契约测试(ctest)的入口。

③ C 源文件会被 Go 工具链视为包的一部分:故 .c 需要 //go:build cgo 约束 (C 编译器把该行当普通注释,两侧兼容)。

2.5 真正的硬耦合点:Lua 适配器

internal/agent/api/provider.go 的 LuaAdaptedProvider 在每次请求都要 调 Lua VM(internal/lua/vm.go,基于 gopher-lua,679 行):

调用点 provider.go 行 作用
CallTransformRequest :403, :872 改写请求体(适配器协议知识)
GetAdapterEndpoint :408, :877 决定 endpoint
CallTransformResponse :439 改写响应
BuildHeaders / GetAdapterHeaders :487, :489 动态签名头
TransformError :900 错误归一化
CallTransformStreamChunk :950 流式分片改写

结论:「把 provider.go 整体 C 化」是不可行的——它把一个嵌入式 Lua 解释器(带 GC、协程)拖进 C。可行的切法是只 C 化 Lua 之外的部分: 把 Lua 当作「回调钩子」,C 侧定义钩子接口,Go 侧注入 Lua 实现。


三、切分方案:按「无状态 → 有状态」分三层

L1 · 协议编解码(本轮目标,纯函数,零状态)

最适合先下沉。全部是 string in → struct out:

目标函数 现位置 说明
parseOpenAICompatibleResponse provider.go:499 非流式响应解析
parseOpenAICompatibleSSEBody provider.go:541 SSE body 整段解析
parseOpenAICompatibleStreamChunkFull provider.go:759 流式分片解析
normalizeOpenAIToolCalls provider.go:628 工具调用归一
normalizeStreamToolCalls provider.go:674 流式工具调用归一
ModelContextWindow provider.go:273 模型名 → 窗口(纯映射)
EstimateTokens / TruncateByTokens tokenbudget.go 纯计算
ComputeTokenBudget tokenbudget.go:52 纯计算(依赖上面两个)

这些函数不碰网络、不碰 Lua、不碰 goroutine,是最安全的起点。 ModelContextWindow / EstimateTokens / ComputeTokenBudget 三个更是 同一组纯算术,可作为「第一个能跑通端到端 C 调用」的最小切片。

L2 · Provider 编排(暂不动)

LuaAdaptedProvider.Chat / ChatStream:HTTP + Lua 钩子 + SSE 流式, 强耦合 Go 的 net/http 与 context。C 化的收益低于风险,暂不动。

L3 · 工具循环 / 调度(明确不 C 化)

task.go / toolcall.go / eventloop.go / scheduler.go:有状态、 与记忆层和 goroutine 调度深度耦合。C 化会摧毁可维护性,不做。


四、落地结构(已落地,2026-09-25)

internal/agent/api/
├── provider.go              # 不动(L2)
├── codec.go                 # 统一符号名(调用方只见这里)
├── codec_cgo.go             # //go:build cgo  → 调 C
├── codec_nocgo.go           # //go:build !cgo → 转发到纯 Go
├── codec_pure.go            # 纯 Go 实现(回退 + 黄金对照基准)
├── codec_golden_test.go     # 黄金对照:C 与纯 Go 逐值相等
├── ha_codec.h  -> ../../../csrc/include/ha_codec.h   (符号链接)
└── ha_codec.c  -> ../../../csrc/src/ha_codec.c       (符号链接)

csrc/                        # C 实现(主仓,非 SDK)
├── CMakeLists.txt           # 供 C 侧独立复用与 ctest(不参与 Go 构建)
├── include/ha_codec.h       # 对外 C 接口(冻结契约)
├── src/ha_codec.c           # L1 编解码(当前:窗口推断 + token 估算/截断)
└── test/test_ha_codec.c     # C 侧契约测试

★ 为什么是符号链接而不是 #include 包外源:Go 构建缓存不跟踪包外被 #include 的 C 文件(实测:改包外源后 go test 仍报 ok,静默用旧代码)。 详见 §2.4 ①。

接口设计原则(已遵守):

  1. C 接口只吃 const char* + 长度,出数值/JSON 串——不传 Go 指针、 不回调 Go(回调留给 L2 的 Lua 钩子层,不在本轮)
  2. C 侧不 malloc 长期持有的内存;调用方给缓冲区,或用「申请/释放」成对 API 并在 Go 侧 defer 释放
  3. ha_codec.h 一旦定下就是冻结接口,与 SDK 冻结同一标准

五、验收方式(黄金对照,缺一不可)

C 化的正确性不能靠「跑起来没崩」,必须有可复现的对照。三类证据:

  1. 黄金对照测试:同一组输入分别喂 C 实现与 Go 实现,断言输出逐字段相等。 现有测试可直接复用做基准:
    • internal/agent/api/sse_body_test.go(4 个 Test)
    • internal/agent/api/context_window_test.go(2 个)
    • internal/agent/core/stream_accumulate_test.go(9 个)
    • internal/agent/core/tokenbudget_test.go
  2. 双构建全绿:CGO_ENABLED=1 go test ./... 与 CGO_ENABLED=0 go test ./... 都必须通过(后者走纯 Go 回退)。CI 要同时跑。
  3. 契约测试:ha_codec.h 的每个函数有对应 C 单测(参照 remotedevice/test/test_ha_remotedevice.c 的写法,gcc ... -lpthread 直编)。

六、第一步:最小可验证切片(已完成,2026-09-25)

目标:只 C 化一个纯函数族,跑通「Go → cgo → C → 返回」全链路, 证明结构可行,再谈扩张。

选 ModelContextWindow + EstimateTokens + TruncateByTokens (三个纯函数,无依赖,逻辑确定,测试齐备)。下表为实际落地情况:

# 计划项 落地
1 csrc/include/ha_codec.h 声明三函数 ✅(含接口冻结声明与哨兵值约定)
2 csrc/src/ha_codec.c 纯 C 实现 ✅(表驱动 switch + 手写 UTF-8 步进)
3 codec_cgo.go / codec_pure.go 双实现 ✅(+ codec_nocgo.go 转发层)
4 csrc/CMakeLists.txt 产出静态库 ✅(但 Go 不链接它,见 §2.4)
5 Go 侧构建集成 ✅ 改为包内符号链接 + cgo 编译 C 源
6 黄金对照测试 ✅ codec_golden_test.go(手写用例 + 2000 次随机对拍)
7 CGO_ENABLED=0 下全绿 ✅ make check-codec-paths 钉死两条路径

额外钉死的约束(原计划未列,实测后补):

  • 变异测试必须真判红:改 C 侧 result = 131072 → 777, go test 必须 FAIL。这是「C 路径真的生效」的证据(不是「跑起来没崩」)。 ★ 此测试抓到过两种静默失效:包外 #include 漏跟踪、构建缓存隐藏改动。
  • cc 交叉编译必须可过:make build-linux-arm64 产出 ELF aarch64。
  • C 侧契约测试:make csrc-test(ctest)与 Go 侧黄金对照互补。

这一步的价值不在功能(这三个函数 Go 版没问题),而在打通链路、 钉死双路径约束、建立黄金对照范式——后面每扩一个函数都复用它。


七、已知风险与未决问题

风险 现状 处置
Windows CGO_ENABLED=0 编不出来 前提已消失(Windows 原生已放弃,见 §2.3) 回退保留但理由换成「CGO-free 目标」
Lua 适配器无法 C 化 硬耦合 gopher-lua 钩子化,Lua 留在 Go 侧(L2 做)
C 库构建谁触发 ✅ 已解决:Go 不依赖预构建库,直接编包内符号链接的 C 源 —
包外 C 源会被缓存漏跟踪 ✅ 已避坑:实测确认,改用包内符号链接 扩张时勿改回 #include 包外路径
JSON 解析能力不足 ha_json.c 只存 int(无 float/long) 用前须评估;必要时扩该库(会动 SDK,需走 SDK 冻结流程)
接口冻结 — ha_codec.h 冻结标准对齐 SDK
打包脚本 ✅ 不再需带 .a(编源码,无外部产物依赖) 扩张到多文件 C 实现时重评

仍未决(需 jianf 拍板):

  1. C 实现放主仓 csrc/ 还是 SDK third_party/homeagent-sdk/?
    • 当前已在主仓 csrc/;若 SDK 侧也要复用,需定同步机制
    • 放 SDK:天然跨端复用,但要走 SDK 冻结与大版本流程
  2. ha_json.c 是复用(从 remotedevice 复制/提为公共)还是新写? 复用会动 SDK 目录结构。
  3. 下一个切片选谁?L1 剩下的是协议编解码(parseOpenAICompatible*、 normalize*ToolCalls 等,见 §三 L1 表);该层依赖 JSON 解析 ⇒ 先解第 2 题。

7.1 ★ 跨语言开销基线(实测已补,2026-09-25)

原 §七 写着「需先有真实延迟基线,当前没有」。现已补上 (internal/agent/api/codec_bench_test.go,go test -bench):

基准 C(经 cgo) 纯 Go 谁快
ModelContextWindow(短 ASCII) 175 ns 38 ns Go 快 4.6×
EstimateTokens / 空串 100 ns 0.43 ns Go 快 230×
EstimateTokens / 短 ASCII 115 ns 6.5 ns Go 快 17×
EstimateTokens / 短中文 100 ns 29 ns Go 快 3.4×
EstimateTokens / 中200字 229 ns 509 ns C 快 2.2×
EstimateTokens / 1KB 中文 840 ns 2870 ns C 快 3.4×
EstimateTokens / 1KB ASCII 2318 ns 332 ns Go 快 7×
TruncateByTokens / 短中文 233 ns 54 ns Go 快 4.3×
TruncateByTokens / 1KB 中文 3923 ns 6918 ns C 快 1.8×

结论(不要凭直觉,数据说话):

  1. cgo 的固定开销约 95–100 ns/次,小输入下完全压倒算法差异。
  2. C 只在长中文(rune 密集、UTF-8 步进重)上明显领先; 长 ASCII 反而 Go 快 7×(Go 的 utf8.RuneCountInString 对 ASCII 有快路径,而 C 侧逐字节跑)。
  3. ⇒ 「C 比 Go 快」是错的;正确表述是「在特定输入分布上更快」。

对函数的建议(按调用分布):

  • ModelContextWindow:调用点单一(provider.go:333,每请求一次), 且输入是短 ASCII ⇒ 拿不到收益。但它应该是冷路径, 175 ns 在单次请求尺度上无关痛痒——关键是别把它放到循环里。
  • EstimateTokens:真正的高频点在 process.go:476 的逐事件循环 (对每条上下文事件算 Source + Input + 40)与 resident.go:552 (对每条上下文算 Input + Response)。字段分布不单一:
    • Source 是短标签("qq" / "webui")⇒ 属 Go 快 17× 那一档
    • Input / Response 是对话文本,长度跨度大:长中文 C 快 2–3.4×, 短文本与长 ASCII 则 Go 快 3–7× ⇒ 没有单一答案:当前一刀切走 C 会让短串净亏。 正确做法是按长度分派(短走 Go、长中文走 C), 但需先用真实长度分布复测——不要凭推测动手。
  • TruncateByTokens:调用点单一(tooldefs.go:38),非热路径。

这不否定 C 化方向,但把「选谁下一个 C 化」的判据从「哪个函数看起来底层」 换成「哪个在真实输入分布下真能变快」。协议编解码(JSON 解析、SSE 分片) 处理的正是长文本——那才是 C 的主场,也是下一步更合理的候选。


实测记录:双路径、缓存跟踪、交叉编译、变异测试、跨语言基准均于 2026-09-25 在本仓实测;工具链与 C 资产核实于本仓 初版核实:2026-09-24 · 落地更新:2026-09-25