Day 18 / 共 20 天 · 第 4 周 横切与生态

类型安全与 Go 泛型

Eino 到处是 Runnable[I,O]Graph[I,O] 这样的泛型。今天讲 Go 泛型速成、Eino 为什么用它、类型擦除在哪发生、编译期检查怎么帮你提前抓 bug。

📍 你在整门课的位置 · 第 4 周 进阶与生态(共 4 周 · 20 天)
D16 callbacks D17 流式深水 D18 类型/泛型 D19 eino-ext D20 构建收官
L01

为什么 Eino 死磕类型安全

🤔 痛点:相邻节点类型对不上,为什么不能等运行到那一步才发现? 你搭了条链:节点 A 输出 *Message,节点 B 却期待 []*Message。在动态语言里,这个"管子接不上"要等程序真的跑到 B、拿到值一解析发现类型不对才崩——可能是上线后、半夜、已经花了几毛钱调了模型之后。Eino 想让这种错在你 go build 时就红着脸报出来。
🧩 错误驱动:没有类型检查会出什么事故? 设想 Eino 不检查类型:你把一个"输出字符串"的节点接到"输入 []*Message"的节点后面,编译通过、部署上线。凌晨 3 点用户请求进来,跑到第二个节点,一个类型断言直接 panic,整个服务 500,你被电话叫醒——而根因只是两个月前连错了一条边。类型安全就是把这类"低级但致命"的错,从运行期强行拽回到编译期。

很多 Python 的 LLM 框架里,节点之间传的是 dict/Any——灵活但危险:字段名打错、类型不对,要运行到那一步才崩。Eino 是 Go,选择用泛型把类型信息带在编排里,让"接不上的管子"在编译期就报错。

类型检查就像生活中的乐高积木凸点对凹槽 泛型的类型对齐就像生活中拼乐高:每块积木的凸点和凹槽形状是固定的,插错位置手上当场就"卡不进去"——你立刻知道错了,而不是把整座城堡搭完、一松手才轰然塌掉。Eino 让"节点 A 的输出凸点"必须严丝合缝对上"节点 B 的输入凹槽",对不上就编译失败。
编译期 vs 运行期报错,差别有多大? 编译期报错 = 你敲完代码、还没运行,IDE 就飘红/编译失败,1 秒钟发现。运行期报错 = 部署上线、跑到那步、可能是半夜、可能已经扣了用户的钱,才崩。Eino 用类型系统把大量错误从"运行期"提前到"编译期"——这是它相比动态语言框架最大的工程优势之一,也是 Day 01"fail-closed"哲学的体现。
L02

Go 泛型 30 秒扫盲

Go 1.18+ 支持泛型。[T any] 是"类型参数"——写一份代码,适配多种类型:

// 不用泛型:每种类型写一遍,或用 interface{} 丢失类型
func FirstInt(s []int) int { return s[0] }
func FirstStr(s []string) string { return s[0] }

// 用泛型:一份代码,类型安全
func First[T any](s []T) T { return s[0] }
First([]int{1,2,3})       // T 自动推断为 int,返回 int
First([]string{"a","b"})  // T 推断为 string,返回 string
读法:[T any] 声明一个类型占位符 T,调用时编译器按你传的实参推断出具体类型。好处:既复用一份代码,又保留每种类型的编译期检查(First([]int{...}) 返回的一定是 int,不是 interface{},不用强转)。
一句话 泛型 = "带类型占位符的模板代码"。编译器帮你把占位符替换成真实类型并检查。Eino 用它让"输入类型 I、输出类型 O"贯穿整个编排 API。
L03

Runnable[I, O]:类型贯穿始终

回忆 Day 06 的 Runnable[I, O]——I 是输入类型、O 是输出类型。这两个类型参数从建图一路带到执行:

// Day 06 的接口
type Runnable[I, O any] interface {
    Invoke(ctx, input I) (output O, err error)   // 输入 I,输出 O,编译器盯着
    Stream(ctx, input I) (*StreamReader[O], error)
    // ...
}
// 建图时定死 I/O
g := compose.NewGraph[[]*schema.Message, *schema.Message]()  // I=[]Message, O=Message
r, _ := g.Compile(ctx)   // r 是 Runnable[[]*Message, *Message]
out, _ := r.Invoke(ctx, msgs)   // 传错类型?编译不过
读法:Invoke 时传的 input 必须是 I 类型、拿到的 output 一定是 O 类型——编译器强制。传个 string 进要 []Message 的图?编译直接失败,根本跑不起来。
📝 简化版 → 真实版:如果让你写一个"类型贯穿"的接口

如果让你自己设计,最朴素的写法可能是用 interface{}

// 你的极简版:能跑,但类型全丢了
type Runnable interface {
    Invoke(input interface{}) (interface{}, error)   // 进出都是 any
}
out, _ := r.Invoke("hello")   // out 是 interface{},得自己强转,转错就 panic

Eino 的真实版多了两个类型参数 [I, O any]

type Runnable[I, O any] interface {                 // ← 多出的 [I,O]
    Invoke(ctx context.Context, input I) (O, error)  // 进 I 出 O,编译器盯着
}
真实版多出的 [I,O] 解决了什么? 极简版里 outinterface{},你必须 out.(*Message) 强转,转错要到运行时才 panic;真实版里 out 直接就是 O 类型,IDE 能补全、传错编译就红。多写的这几个字母,换来的是"错误提前到编译期 + 全程自动补全"。
L04

泛型层 vs 无类型层(关键设计)

但引擎内部(Day 09 的调度、channel)没法对每种类型都写一遍——那要写无数份。Eino 的解法是两层

外层:泛型 API(面向你)Graph[I,O]Runnable[I,O]。给你完整的编译期类型检查。generic_graph.go
内层:无类型引擎(面向实现) — 内部 graph、channel 用 any 存值。一份代码跑所有类型。graph.go
外层泛型包装内层无类型Graph[I,O]generic_graph.go:93)内部持有一个无类型的 graph。你在外层享受类型检查;进入引擎时值被"装箱"成 any 跑通用逻辑;出引擎时再"拆箱"回 O 类型交还给你。Day 06 提过的 composableRunnable(无类型)和 Runnable[I,O](泛型)的转换(toGenericRunnable),就是这两层的桥。
L05

类型擦除在哪发生

"类型擦除"= 把带类型的值变成 any(丢掉静态类型,靠运行时反射记住真实类型)。发生在泛型层 → 无类型层的边界

// 概念:进引擎时装箱,出引擎时用反射拆箱
func toGenericRunnable[I,O any](cr *composableRunnable) Runnable[I,O] {
    return &runnablePacker[I,O]{
        i: func(ctx, in I) (O, error) {
            out, err := cr.i(ctx, in)        // in 作为 any 传给无类型引擎
            return out.(O), err              // 结果断言回 O(这里靠运行时类型信息)
        },
    }
}
读法:值进引擎时当 any(擦除静态类型),出来时用类型断言 .(O) 恢复。因为编译期已经保证了类型对齐(L06),这个断言几乎不会失败。擦除只是"引擎内部为了通用不看类型",边界两头仍是强类型的。
类型擦除就像生活中机场托运行李 就像生活中托运行李:进安检口(进引擎)你的箱子被贴上统一条码、丢进通用传送带(变成 any,传送带不管你箱子里装的是衣服还是相机);到了目的地转盘,你凭条码把自己那只箱子认领回来(.(O) 断言恢复成原类型)。传送带通用(一份引擎代码跑所有类型),但你拿到的还是你原来那只箱子(强类型)。
L06

编译期 + 编译时双重类型检查

Eino 的类型检查其实分两个阶段:

  • Go 编译期(你 go build 时):泛型保证 Invoke 的输入输出类型对。这是编译器免费给的。
  • Compile 时(Day 09,运行时的建图阶段):检查相邻节点的"上游 O → 下游 I"能否对上。因为引擎内部是无类型的,节点间连接的类型对齐得靠 Compile 时用反射逐边检查。
为什么需要第二阶段? 因为你 AddNode/AddEdge 时,编译器只知道每个节点各自的 I/O,但"A 的输出能不能喂给 B"这种跨节点的对齐,泛型表达不了(节点存进无类型引擎后类型信息在编译器眼里就模糊了)。所以 Compile 时再用节点登记的反射类型信息做一次逐边校验,对不上就 Compile 返回 error。两道关卡,尽最大可能在"跑第一个 token 之前"抓住类型 bug。
⚠️ 小白常误以为:既然 Go 有编译期泛型检查,那所有类型错误 go build 都能抓到。其实不然——跨节点那条边(A 的输出喂给 B 的输入)编译器看不见,因为节点一存进无类型引擎,编译器眼里它们的类型就"糊"了。这条边要靠 Compile(ctx) 运行时用反射再查一遍。所以:连错边不会编译失败,而是 Compile 返回 error——但它仍在"跑第一个请求之前",依然算提前拦截。
编译期类型检查:A 的输出类型 必须 = B 的输入类型 ✅ 对得上(编译通过) 节点 A输出 *Message ● 节点 B● 输入 *Message 凸点 ● 对上凹槽 ● ❌ 对不上(当场报错) 节点 A输出 string ▲ 节点 B● 输入 *Message ▲ 插不进 ● → 报错
像乐高凸点对凹槽:A 的输出类型与 B 的输入类型必须一致,对不上在编译期/Compile 时当场报错。
L07

权衡与代价

  • 好处:大量错误编译期/编译时暴露、IDE 自动补全好用、重构安全、自文档化(看签名就知道吃什么吐什么)。
  • 代价:泛型让类型签名变长(Runnable[[]*schema.Message, *schema.Message] 读着累);两层架构 + 反射有一点复杂度和运行时开销;报错信息有时较长。
Eino 的取舍 它认为"生产级 Agent 框架,可靠性 > 一点点便利"。宁可类型写长点、内部复杂点,也要把错误挡在上线前。对于要长期维护、多人协作、跑真金白银业务的系统,这个取舍是对的。这也是为什么严肃后端团队偏爱 Go/强类型来做 Agent 基础设施。
🗣️ 一句话复述 Eino 用泛型 [I,O] 让"输入输出类型"跟着编排走,外层给你编译期检查、内层擦成 any 跑通用逻辑,再加 Compile 时反射逐边校验——就为把类型 bug 从半夜的线上事故,提前到白天的编译红线。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 为什么 Eino 死磕类型安全?编译期 vs 运行期报错差别?
  • Go 泛型 [T any] 是什么?
  • 泛型层和无类型引擎层怎么分工?
  • 类型擦除发生在哪?怎么恢复类型?
  • 两个阶段的类型检查分别在什么时候?

✋ 动手

grep -n 'func New.*\[.*any\]\|Runnable\[' compose/runnable.go | head
sed -n '93,120p' compose/generic_graph.go     # 泛型 Graph 包无类型 graph
grep -rn 'reflect\.' compose/graph.go | head   # 逐边类型检查用的反射
明天预告 · Day 19eino-ext 生态——Eino 主仓只有接口和引擎,真正的模型/工具/向量库实现都在 eino-ext。今天看生态版图、怎么接入一个 OpenAI 模型、社区组件长啥样。
← Day 17 流式深入 Day 19 · eino-ext 生态 →