纬线:一次把组合前移到构建期的尝试

我试着用编译期组合替掉运行期配置,让一个场景对应一个二进制。范式讲得通,代码也跑得起来,但我最终停在这里:它不错,只是太早。

weft 写于 2026 年 9 月 14 日,是一个用来验证这套设想的 Go 模块。下面把设想讲完整,再说我为什么停在这里。

问题:平台把复杂度留在了运行期

主流做法是造一个智能体平台:一套通用运行时,加一层可视化配置界面,靠开关、插件、租户配置去适配不同场景。它在规模化之前很舒服,规模化之后会撞上同一类问题。

  • 配置面无限膨胀。平台的配置项是所有功能的并集,而且只增不减。没有人能说清,某个场景下到底哪些配置项真正生效。
  • 运行期充满分支。每个功能都要处理「没启用」的情况,组合出来的行为无法穷举,也无法测试。
  • 平台团队成为瓶颈。每个业务场景的需求都要回到平台上排队,平台为了通用性不断叠加抽象层。
  • 每个场景都在为别人的复杂度买单。镜像更大、依赖更多、启动更慢、攻击面更宽。

我想说的根因只有一句:组合发生在运行期,复杂度就只能推迟到运行期解决。那把它挪到构建期,这些麻烦是不是就跟着挪走了?

设想:组件在编译期组合,场景在构建期固化

weft 换一个方向,把一件事拆成四步:

  1. 组件。把智能体需要的能力拆成一个个独立组件:模型接入、工具调用、记忆、检索、渠道、护栏、审计。它们可以预先开发,也可以等真实场景需要时再开发。
  2. 配方。针对一个具体场景写一份配方,声明「这个场景需要哪些组件、各自什么版本」。不需要的组件不出现在配方里。
  3. 编译。按配方把组件织进一个二进制,没有用到的代码不在二进制里。
  4. 运行。这个二进制连同它需要的配置,在容器里跑起来。

于是传统平台是一个二进制,N 种配置;weft 是N 个二进制,一个场景一份配置。

weft 的意思是「纬线」。经线预先张好,纬线按纹样穿过去,织成布。组件是线,配方是纹样,二进制是布。名字本身就说明了我当时想要的东西:布是织出来的,不是裁出来的。

组件只向织机交代四件事

整个范式落在组件契约上。契约只交代四件事:身份、配置面、提供的服务、要跑的入口。少一件都织不起来,多一件都会让每个组件都背上不属于自己的负担。一个组件就是一个 Go 包,导出唯一一个实现了契约的值:

var Component = &component{}

func (c *component) Name() string { return "llm.dummy" }   // 身份,也是配置命名空间
func (c *component) Config() any  { return &c.cfg }        // 配置面
func (c *component) Package() func(do.Injector) { /* 提供的服务 */ }
func (c *component) Entries() []weft.Entry { return []weft.Entry{weft.Use[*Client]()} }

配置就地声明在结构体上,字段名推导键名。身份加键名推导出环境变量名,所以llm.dummy 这个身份加 api_key 这个键,得到的是WEFT_LLM_DUMMY_API_KEY。没有谁来分配前缀,也不会撞车。

type Config struct {
    APIKey  string        `weft:"api_key" required:"true" secret:"true"`
    BaseURL string        `weft:"base_url" default:"https://example.invalid/v1"`
    Timeout time.Duration `weft:"timeout" default:"5s"`
}

配方全文只有这么多:

func main() {
    boot.Main(dummyllm.Component)
}

配方是 Go 代码而不是配置文件,所以「引用了不存在的组件」根本编译不过。组合在编译期就被编译器兜住了。

我期望得到什么

  • 配置面是封闭的。一个二进制需要哪些配置项,由它链接了哪些组件唯一决定。二进制能准确说出自己要什么,部署前就能核对,不存在「这个开关到底有没有生效」的疑问。
  • 缺配置就拒绝启动。启动时先做配置自检,一次性列出全部缺失或非法的配置项再退出。运维拿到的是完整待办清单,而不是一项一项试错。
  • 二进制即契约。产物不可变、可复现、可审计。同一个配方、同一份配置,行为一致;要改行为,就出一个新二进制。
  • 没有运行期插件机制。插件沙箱、动态加载、ABI 兼容层,以及它们带来的一整类故障与攻击面,在这个范式里不存在。
  • 部署无依赖。静态二进制加环境变量就能跑,容器编排、systemd、边缘节点一视同仁。

还有一条我当时写下来、现在依然认同的理由:智能体的能力天然是拼装式的。一个客服智能体和一个代码审查智能体,差别主要在接了哪些模型、哪些工具、哪些知识库、哪些渠道。用平台去覆盖这种差异,平台会被迫长成一个「什么都能配」的怪物;用配方去覆盖,每个智能体只带走自己真正用到的那几根线。

做出来了什么

做出来的是三块东西:组件契约、组装根 boot,还有大模型调用的唯一一份说法 llm。

boot.Main 是配方唯一的调用点,顺序是固定的:自检配置面 → 注册服务 → 启动入口 → 等信号 → 优雅关停。任何一步失败都以非零退出码结束,容器编排据此判断该不该重启。配置自检失败时,进程打印的是这一份二进制完整的配置待办清单,而不是第一个错误。

llm 不是组件,是一组类型加一个方法,是「怎么把消息、工具交给大模型,又怎么把回应流拿回来」的唯一一份说法:

type Provider interface {
    Do(ctx context.Context, req llm.Request) iter.Seq2[llm.Event, error]
}

一次调用产出一条事件流:文本增量、工具调用、结束。消息里的内容块是并列的:文字、工具调用、工具结果、图片。图片带着自己的媒体类型和来源(一段字节,或者一个链接),所以以后加音频、视频不用再动接口。

唯一一个假 Provider 是 llm/dummy:配置只剩一个 Prefix,不管你说什么,它挑出最后一条 user 消息,把前缀加在原文前面,一个 rune 一个 rune 地流式吐回来。它读不懂消息里的媒体,但会明说收到了几块,而不是装作没看见。它存在的意义,是让整条链路在没有模型、没有密钥的情况下也能端到端跑通。

这是当时我最满意的一个决定:接哪家模型是 llm.Provider 的一个实现,是独立组件;抽象包本身不依赖任何厂商,也不替厂商做白名单。真正跨厂商成立的参数只有 TopP 和 MaxOutputTokens,那就只留这两个,各家自己的花样留在各自的 Provider 里。

为什么放弃

代价我在 README 里列全了:构建次数增加、改动需要重新构建、组件契约必须少而稳、需要版本治理。我写的时候把它们当成可以接受的成本。真让我停下来的是,这份清单里少了一行,而少的那一行是决定性的。

少的这行是:这个范式没有给探索算过账。它优化的是稳定态下的运维成本;而探索期的瓶颈是改一次要多久才能看到结果。在 weft 里,换一个模型、加一个工具、调一次提示词的拼法,都得走一遍改配方或写组件、编译、构建镜像、重新部署,然后才能看一眼。每一步都不贵,加起来就足以让「试一下」变得不划算。

另一个让我迟疑的是织机始终没落地。可以复现地织出同一个二进制,是这个范式最核心的收益,而它取决于织机;织机没做出来,配方就只是我手写的一个main.go,版本靠 go.mod笼统地约束着。收益还停在纸面上,成本却已经全部发生了。

还有一点是我低估的:我对场景的判断就是错的。编译期组合的前提是场景集合相对稳定,而我并不知道要做的到底是哪几个场景。需求还在长,配方已经写死了。

这不是错的,是早的

我现在依然觉得这个方向是对的,只是不适合我当时的位置。它适合这样一支平台团队:

  • 场景集合已经稳定下来,数量到了几十上百个;
  • 确实需要每个场景独立可复现、可审计、可回滚;
  • 镜像体积、启动时间、攻击面是真的约束,而不只是纸面上的好处;
  • 构建链路足够快,快到「重新出个二进制」和「改个配置」的体感差别可以忽略。

这四条同时成立的时候,「一个二进制,N 种配置」的那些毛病会重新变得刺眼,weft 也就重新划算了。在这之前,运行期的可配置性仍然是更便宜的选择,代价是复杂度留在运行期,换来的是「试一下」近乎零成本。

我停在这里,weft 留在仓库里,算一份为以后留着的灵感。等到场景定下来、数量涨起来的那天,我会把它重读一遍,大概率会发现要改的地方比想象中多。那天真正的前提是,我已经知道自己在织什么布。