Files
HomeAgent/docs/zh/c-core/llm-orchestration-c.md
JianFeeeee e3867b3481 feat(c-core): 内核编解码层 C 化第一刀 —— L1 纯函数层落地并打通构建链
第一刀只做三个纯函数(窗口推断 / token 估算 / token 截断),
价值不在功能(Go 版没问题),而在打通「Go → cgo → C」全链路并
建立可复现的对照范式,后面每扩一个函数都复用它。

## 为什么是这三个

按「无状态 → 有状态」分层,L1 协议编解码最安全:纯 string in → struct out,
不碰网络、不碰 Lua、不碰 goroutine。三个函数更是同一组纯算术,最小可验证切片。

## 关键设计:包内符号链接,不链接静态库、不 include 包外源

三种做法都实测过,只有一种同时满足「可构建 + 可交叉编译 + 缓存可跟踪」:

1. ❌ 链接 `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`

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

3. ✅ 包内符号链接 `internal/agent/api/ha_codec.{c,h}` → `csrc/`
   文件在包目录内 ⇒ 缓存按内容正确跟踪;只有一份权威源 ⇒ 无副本漂移,
   也不需要「同步 C 源」的 make 目标。

## 不需要额外 build tag

ha_codec 是零依赖纯 C99 源码内联编译,不需要外部库或工具链前提;
而 homed 本就强制 cgo(sqlite3 + gojieba),故 C 路径自然生效。
只用 `cgo` / `!cgo` 一组约束(对比 onnxruntime:那个需运行期 .so,故必须显式 tag)。

## 顺带修掉的既有缺陷(非 C 化引入,但一直缺覆盖)

- Makefile 的 arm64 目标缺 CC/CXX:cgo 回退到宿主 g++,报
  `gcc_arm64.S: no such instruction: 'stp x29,x30,[sp,'`
  (deploy/packaging/build.sh:49 一直是对的,Makefile 漏了)
- Makefile 的 arm64 目标缺 .syso 隔离:cmd/{homed,waiter}/*.syso 是 Windows
  COFF 资源对象,Go 会把同目录 .syso 无条件链进任何目标,交叉到非 Windows
  平台报 `file format not recognized`(build.sh 有 hide_syso_for_target)

## 验证(每条可复现)

- `make build-linux-arm64` → ELF 64-bit LSB executable, ARM aarch64(82MB)
- `bash deploy/packaging/build.sh linux/arm64 homed` → ELF aarch64(79MB)
- 移走 csrc/build/ 后 homed(cgo)与 waiter(CGO=0)均能构建
- 变异 C 源(131072→777)后**同一缓存**下 go test 立即 FAIL(改前:仍报 ok)
- `make check-codec-paths` 两条路径 OK;`make csrc-test` C 契约测试 100%
- 黄金对照:手写用例 + 2000 次随机对拍,C 与纯 Go 逐值相等
- 全量 `go test -count=1 ./...` → 57 包:38 ok + 19 无测试 + 0 FAIL

新增 `make check-codec-paths` 防回归:只测一条路径时,另一条的破坏不会被发现。

## 文档

- `docs/zh/c-core/llm-orchestration-c.md` 同步为「已落地」,并更正因
  「Windows 原生已放弃」而过时的 §2.3(回退路径的理由需重述)
- plan.md 的 P0-1 标记为已修复,附实测证据
2026-09-25 14:45:08 +08:00

14 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. 是否接受「C 化后内核体积/构建复杂度上升」换取延迟确定性? (需先有一个真实延迟基线,当前没有)
  4. 下一个切片选谁?L1 剩下的是协议编解码(parseOpenAICompatible*、 normalize*ToolCalls 等,见 §三 L1 表);该层依赖 JSON 解析 ⇒ 先解第 2 题。

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