Runnable 与 4 种流式范式
进入框架心脏。Runnable 是编排产物的统一执行抽象,有 4 种调用方式。理解"组件只实现一个、框架自动补齐四个"这个 Eino 最精妙的设计——是读懂后面一切的钥匙。
为什么要一个统一抽象
Runnable,都有相同的 4 个方法。组件作者只实现对它最自然的 1-2 种(模型实现 Stream/Invoke 就够),其余范式框架靠 schema 里注册的"拼接/包装函数"自动补齐。于是相邻节点的流/非流不匹配,框架替你转,不用你写一行胶水。Day 04 你见过:ChatModel 有 Generate/Stream,Tool 有 InvokableRun/StreamableRun,各组件方法名都不一样。可编排引擎要把它们"当成统一的东西"连起来跑——总不能给每种组件写一套连接逻辑吧?
🎯 Runnable 就是那个"统一插头":任何组件、任何编排产物(Chain/Graph/Workflow),编译后都变成一个 Runnable,都有相同的 4 个方法可调。编排引擎只跟 Runnable 打交道。
Runnable 就是 Eino 的"国标插头"——它抹平了各组件的差异。compose/runnable.go 是本页的主角文件。4 种范式就像 4 种上菜方式:一次端齐(Invoke)、边做边一道道上(Stream)、你分批递食材厨房收齐做一道(Collect)、食材流水线进成品流水线出(Transform)。
自动补齐就像贴心服务员:后厨只会"边做边上",但你想要"一次端齐",服务员就在窗口帮你等齐了再一起端上桌。
Runnable 接口(真实代码)
compose/runnable.go:32 的真实定义——4 个方法,泛型 [I, O](输入类型/输出类型,编译期定死):
type Runnable[I, O any] interface {
Invoke(ctx, input I, opts ...Option) (output O, err error)
Stream(ctx, input I, opts ...Option) (output *schema.StreamReader[O], err error)
Collect(ctx, input *schema.StreamReader[I], opts ...Option) (output O, err error)
Transform(ctx, input *schema.StreamReader[I], opts ...Option) (output *schema.StreamReader[O], err error)
}
Runnable[string, *Message] 意思是"输入是 string、输出是 *Message 的可运行体"。泛型让同一套 Runnable 代码能用于任意输入输出类型,且编译器会帮你检查类型对不对——你不会把一个"输入 int"的 Runnable 误当"输入 string"用。4 范式怎么区分:看输入输出是不是流
4 个方法按"输入是不是流 × 输出是不是流"两两组合(源码注释里的比喻很形象):
ping ⇒ pong。普通调用:给一句,等一个完整答案。
ping ⇒ stream。给一句,边生成边返回(打字机)。
stream ⇒ pong。输入是流,收齐了给完整答案。
stream ⇒ stream。流进流出,一段段处理一段段产出。
Invoke(要完整结果)或 Stream(要打字机效果)。Collect/Transform 用于"输入本身就是流"的场景——比如上一个节点吐流、这个节点要接着处理。关键是:你不用关心组件内部实现了哪几个,任意一个都能调(下一讲揭秘)。对应地,你写自定义逻辑(Lambda)时也有这 4 种函数签名(types_lambda.go:27)。招牌魔法:实现 1 个,自动补齐 4 个
这是 Eino 最精髓的设计。源码注释直说(runnable.go:29):
翻译:组件只实现了其中一两个方法,框架能自动"降级/升级"补齐另外几个。比如 ChatModel 只提供了 Generate(≈Invoke)和 Stream(component_to_graph_node.go:93,Collect/Transform 传 nil),但编译后你依然能对整张图调 Collect/Transform。
Stream("北京天气?") 吐出 ["北","京","今","天","晴"] 一串 chunk。你偏偏调
Invoke("北京天气?") 要完整值 —— 框架发现没有原生 Invoke,就自动"先 Stream 拿到那串 chunk,再用 ConcatMessages 拼接",最终返回给你完整的 "北京今天晴"。你的代码只写了一行
Invoke(...),拼接的胶水框架全包了。ConcatMessages)。反过来,一个只会返回完整值的组件,框架也能把它包成"单元素流"。结果:组件作者只写对它最自然的 1-2 种,用户四种随便调。👨🏫 老师:那等于让每个厨师既练"一次端齐"又练"边做边上",重复劳动还容易做得不一致。让作者只写最自然的(模型天生会 Stream),其余交给统一的"服务员",省事又统一。
👶 小白:那自动补出来的,和作者亲手写的效果一样吗?
👨🏫 老师:功能一样,但"体验"可能降级——只有 Stream 却调 Invoke,得等所有 chunk 到齐拼好才返回:结果对,但没了"逐字"的即时感。想要真流式,链路每环都得原生支持流(见 L07)。
补齐优先级表(真实代码)
自动补齐发生在 newRunnablePacker(runnable.go:345)。它是一张"优先级表"——补 Invoke 时,自己有就用自己,否则依次尝试用 Stream/Collect/Transform 转:
// 朴素想法:没有 Invoke?那就用 Stream 拼一下
func fillInvoke(s StreamFunc) InvokeFunc {
return func(in) Out { return concat(s(in)) } // 只想到用 Stream 兜底
}
问题:万一组件连 Stream 都没有、只有 Collect 或 Transform 呢?你的兜底就崩了。真实源码把"备胎"排成一条优先级链(Stream→Collect→Transform 逐个试),保证只要 4 个里有任意 1 个就能补出来 👇// runnable.go:368 —— 补 Invoke
if i != nil {
r.i = i // 自己有 Invoke,直接用
} else if s != nil {
r.i = invokeByStream(s) // 否则:用 Stream 转(拿到流再拼成完整值)
} else if c != nil {
r.i = invokeByCollect(c) // 或:用 Collect 转(把输入包成单元素流)
} else {
r.i = invokeByTransform(t) // 或:用 Transform 转
}
// Stream / Collect / Transform 各有类似的优先级链(runnable.go:378-406)
用一张矩阵直观看"从谁能推出谁":
| 目标\来源 | 有 Invoke | 有 Stream | 有 Collect | 有 Transform |
|---|---|---|---|---|
| 要 Invoke | 直接用 | 拼流 | 包单元素流 | 拼流 |
| 要 Stream | 包成单块流 | 直接用 | — | 真流式 |
| 要 Collect | 拼输入+Invoke | — | 直接用 | 真流式 |
| 要 Transform | 拼输入+包块流 | — | — | 直接用 |
4 个方向的转换(血肉)
补齐靠的是四类转换函数(runnable.go:203-343)。理解这三种基本操作就懂了全部:
- 流 → 单值:
concatStreamReader把所有 chunk 拼成完整值(用 Day 03 的ConcatMessages)。例:invokeByStream——先 Stream 拿流,再拼成单值。 - 单值 → 流:把单值包成"只有一个元素的流"——
StreamReaderFromArray([]O{out})(runnable.go:243的streamByInvoke)。 - 单值喂给需要流的方法:把单值包成单元素流再喂进去,如
invokeByCollect(runnable.go:214)。
Invoke(要完整值)→ 可从任何流方法"拼接(concat)"降级得到。Stream(要真流)→ 真流式优先来自 Transform 或 Stream 自身;用 Invoke 降级会退化成"单块流"(就一个大块,没了逐字效果)。Collect → 先把输入流拼成单值,再走 Invoke。Transform → 真流式;用 Invoke 降级 = 先拼输入、算完再包成单块流。真流式的代价:一个节点破功,全链凝固
魔法虽好,但有个重要认知(初学者必懂):真正的"逐字流式"只有当链路上每个节点都提供了 Stream/Transform 时,才能端到端保持。任何一个节点只有 Invoke,流到那里就会被 concat 成整块。
模型(Stream) → 过滤(只有 Invoke) → 输出。模型吐流,但过滤节点只会 Invoke(要完整输入)——框架只能先把模型的流拼成完整消息再喂给过滤。于是"逐字"效果在过滤这里就没了,后面即使想流也是"一整块"。所以想要端到端打字机效果,链路上每个节点都得是流式的。源码在 chain.go:264 特别提醒:想要真流式输出,请用 StreamableLambda/TransformableLambda 写你的自定义节点。[I,O](类型安全),但内部执行时用 any(无类型)——composableRunnable(runnable.go:46)只保留 i(处理非流)和 t(处理流)两个函数 + 三个 reflect.Type 做类型校验。编译好的图最后又用 toGenericRunnable(runnable.go:411)包回强类型接口给你。外层强类型(好用、安全)、内层无类型(灵活、统一)——这是 Go 泛型 + 反射的经典配合。今日小结 + 动手
🧠 今天你应该能回答
- Runnable 是什么?为什么需要它?(编排产物的统一执行插头)
- 4 种范式怎么区分?(输入是不是流 × 输出是不是流)
- "实现 1 个补齐 4 个"怎么做到?(newRunnablePacker 的优先级表 + 转换函数)
- 三种基本转换?(流→单值拼接、单值→单块流、单值→单元素流)
- 为什么"一个非流节点会让全链凝固"?想要真流式怎么办?
✋ 动手:对着真实代码读一遍
# 1. Runnable 接口(L02)
sed -n '32,40p' compose/runnable.go
# 2. 补齐优先级表(L05,本页精华)
sed -n '345,409p' compose/runnable.go
# 3. 4 个方向的转换函数(L06)
sed -n '203,260p' compose/runnable.go
# 4. Lambda 的 4 种函数签名
sed -n '27,39p' compose/types_lambda.go