# 内核 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 //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*