21流机制与状态管理
Copy 的 copy-on-read 链表、MergeNamedStreamReaders、装箱、ProcessState 按类型逐级查找。
schema/stream.go:261schema/stream.go:837schema/stream.go:912compose/state.go:165两块压轴基石:流与状态
Part IV 走到这里,执行内核(循环 + channel)和三个构建器(Graph/Chain/Workflow)都拆完了。但还有两块基石没碰,而它们恰恰是 ADK 里”流式回复”和”跨轮记忆”的底层支撑:stream(流) 和 state(状态)。这一章我们把它们一起讲清楚——因为它们体现了同一种设计取向:用最省的机制,兑现最强的语义。
flowchart TB
subgraph SRC["五种来源"]
ST["stream · Pipe 管道"]
AR["arrayReader · 数组伪装"]
MSR["multiStreamReader · 合并"]
SRW["…WithConvert · 类型转换"]
CSR["childStreamReader · Copy 子流"]
end
SRC --> SR["StreamReader[T]<br/>tagged union · 按 typ 分派"]
SR --> RECV["Recv() 读一次即干涸<br/>close 恰好一次"]
StreamReader:五种来源,对外同一个类型
流不是数据,是”一次性的取水管”
先纠正一个直觉。Eino 的 StreamReader[T](schema/stream.go:168)不是一个装着数据的切片,而是一根读一次就干涸的管子。文档写得很直白:read-once,只能一个 goroutine 调 Recv,而且必须 close 恰好一次。
// StreamReader is the consumer side of an Eino stream.//// A StreamReader is read-once: only one goroutine should call Recv, and the// reader must be closed exactly once (whether the loop finishes normally or// exits early via break or return).//// Typical usage://// defer sr.Close() // always close, even after io.EOF// for {// chunk, err := sr.Recv()// if errors.Is(err, io.EOF) {// break// }// if err != nil {// return err// }// process(chunk)// }//// To fan-out a single stream to N independent consumers, call [StreamReader.Copy]// before any Recv; the original reader becomes unusable after the call.//// StreamReaders are created by [Pipe], [StreamReaderFromArray],// [MergeStreamReaders], [MergeNamedStreamReaders], and [StreamReaderWithConvert].type StreamReader[T any] struct { typ readerType
st *stream[T]
ar *arrayReader[T]
msr *multiStreamReader[T]
srw *streamReaderWithConvert[T]
csr *childStreamReader[T]}它的入口是 Pipe(schema/stream.go:99),给你一对 reader/writer;writer 那头 Send,reader 这头 Recv 到 io.EOF 为止。但真正精妙的是 StreamReader 的内部结构——它是一个带标签的联合类型(tagged union):
// Pipe creates a new stream with the given capacity that represented with StreamWriter and StreamReader.// The capacity is the maximum number of items that can be buffered in the stream.// e.g.//// sr, sw := schema.Pipe[string](3)// go func() { // send data// defer sw.Close()// for i := 0; i < 10; i++ {// sw.Send(i, nil)// }// }//// defer sr.Close()// for {// chunk, err := sr.Recv()// if errors.Is(err, io.EOF) {// break// }// if err != nil {// panic(err)// }// fmt.Println(chunk)// }func Pipe[T any](cap int) (*StreamReader[T], *StreamWriter[T]) { stm := newStream[T](cap) return stm.asReader(), &StreamWriter[T]{stm: stm}}type StreamReader[T any] struct { typ readerType // 我到底是哪一种 reader?
st *stream[T] // 真·管道(Pipe 来的) ar *arrayReader[T] // 数组伪装成的流 msr *multiStreamReader[T] // 合并出来的多路流 srw *streamReaderWithConvert[T] // 带类型转换的流 csr *childStreamReader[T] // Copy 出来的子流}Recv 就是一个按 typ 分发的 switch。为什么要这么设计?因为 Eino 里”流”有五种来源(管道、数组、合并、转换、复制),但对使用者必须是同一个类型。用一个 union + 一个 tag,既避免了接口装箱的开销,又让五种流对外完全一致。这是整章的第一个设计信号:统一对外类型,内部按需分派。
Copy:读时复制的单链表
流是读一次就没的,那”一个大模型的输出,既要给用户看、又要喂给下一个节点”怎么办?答案是 Copy(n)(schema/stream.go:261)。
// Copy creates n independent StreamReaders that each receive every element of// the original stream. The original StreamReader becomes unusable after Copy.//// Use Copy when two or more pipeline branches need the same stream —// for example, when a stream must be fed to both a callback handler and the// next node in a graph://// copies := sr.Copy(2)// sr1, sr2 := copies[0], copies[1]// defer sr1.Close()// defer sr2.Close()//// // sr1 and sr2 independently read the same elements//// n must be at least 1. If n < 2, the original reader is returned unchanged.func (sr *StreamReader[T]) Copy(n int) []*StreamReader[T] { if n < 2 { return []*StreamReader[T]{sr} }
if sr.typ == readerTypeArray { ret := make([]*StreamReader[T], n) for i, ar := range sr.ar.copy(n) { ret[i] = &StreamReader[T]{typ: readerTypeArray, ar: ar} } return ret }
return copyStreamReaders[T](sr, n)}难点在于:上游流有多长你事先不知道,而 n 个消费者各读各的、进度不一。Eino 的解法非常漂亮——一个读时复制(copy-on-read)的单向链表。核心类型是 cpStreamElement(schema/stream.go:784):
type cpStreamElement[T any] struct { once sync.Once // 保证这一格只从源头读一次 next *cpStreamElement[T] // 指向下一格(读到才创建) item streamItem[T] // 缓存下来的那一块数据}Copy 造出一个 parentStreamReader(schema/stream.go:823)和 n 个 childStreamReader(schema/stream.go:883)。每个 child 记着自己走到链表的哪一格。当某个 child 要 Recv,它落到 peek(schema/stream.go:837),核心就一段:
type parentStreamReader[T any] struct { // sr is the original StreamReader. sr *StreamReader[T]
// subStreamList maps each child's index to its latest read chunk. // Each value comes from a hidden linked list of cpStreamElement. subStreamList []*cpStreamElement[T]
// closedNum is the count of closed children. closedNum uint32}type childStreamReader[T any] struct { parent *parentStreamReader[T] index int}elem.once.Do(func() { t, err = p.sr.Recv() // 只有第一个到这格的 child 真的去源头读 elem.item = streamItem[T]{chunk: t, err: err} if err != io.EOF { elem.next = &cpStreamElement[T]{} // 顺手把下一格铺好 p.subStreamList[idx] = elem.next }})// 后到的 child 直接读缓存好的 elem.item,不碰源头sync.Once 是这里的灵魂:**同一格,无论多少个 child 经过,只有第一个真去源头 Recv 一次,其余的读缓存。**跑得快的 child 沿着链表往前铺路,跑得慢的沿着同一条链追,谁都不会重复消费源头,也不用等齐。等所有 child 都 close,源头才被 close(close 里用 closedNum 计数,schema/stream.go:868)。
func (p *parentStreamReader[T]) close(idx int) { if p.subStreamList[idx] == nil { return // avoid close multiple times }
p.subStreamList[idx] = nil
curClosedNum := atomic.AddUint32(&p.closedNum, 1)
allClosed := int(curClosedNum) == len(p.subStreamList) if allClosed { p.sr.Close() }}🔑 本章的设计钥匙
流与状态,共享同一条设计哲学:用最小的机制承载最强的语义,并把成本推迟到真正需要的那一刻。 流的
Copy不预读、不缓冲整条流,而是用sync.Once守护的单链表做惰性的读时复制——快的消费者铺路、慢的追赶,源头每格只读一次;合并用 select 按到达顺序交织而非轮询,顺其自然。状态则不做全局字典,而是把每层图的 state 串成一条parent链,ProcessState[S]靠类型断言逐级上溯去找匹配S的那一层。两者都拒绝”预先算好、集中保管”,都选择”用时才求值、按需才分派”——这正是支撑 ADK 流式与记忆而不拖垮性能的关键。
Merge:按到达顺序交织
Copy 是一变多,Merge 是多变一。MergeStreamReaders(schema/stream.go:912)把 N 路流合成一路。注意它的语义:按到达顺序交织(arrival order),不是轮询,也不保证顺序——哪路先来数据就先吐哪路。只有所有源都 EOF,合并流才 EOF。
// MergeStreamReaders fans in multiple StreamReaders into a single StreamReader.// Elements from all source streams are interleaved in arrival order (non-deterministic).// The merged reader reaches EOF only after every source stream has been exhausted.//// Callers must still close the merged reader; it propagates the close signal// to all underlying sources.//// Use [MergeNamedStreamReaders] instead when you need to know which source// stream ended first (it emits a [SourceEOF] per-source EOF rather than// silently discarding them).//// Returns nil if srs is empty.func MergeStreamReaders[T any](srs []*StreamReader[T]) *StreamReader[T] { if len(srs) < 1 { return nil }
if len(srs) < 2 { return srs[0] }
var arr []T var ss []*stream[T]
for _, sr := range srs { switch sr.typ { case readerTypeStream: ss = append(ss, sr.st) case readerTypeArray: arr = append(arr, sr.ar.arr[sr.ar.index:]...) case readerTypeMultiStream: ss = append(ss, sr.msr.nonClosedStreams()...) case readerTypeWithConvert: ss = append(ss, sr.srw.toStream()) case readerTypeChild: ss = append(ss, sr.csr.toStream()) default: panic("impossible") } }
if len(ss) == 0 { return &StreamReader[T]{ typ: readerTypeArray, ar: &arrayReader[T]{ arr: arr, index: 0, },// … 省略 13 行;完整声明 L900–960,点击上方「浏览完整文件」实现上是 multiStreamReader,它的 recv(schema/stream.go:538)分两条路:源少时(≤5)走一组硬编码的 select(schema/select.go:21),源多时(>5)才动用 reflect.Select。这个 5 的阈值就是 maxSelectNum(schema/select.go:19):
func (msr *multiStreamReader[T]) recv() (T, error) { for len(msr.nonClosed) > 0 { var chosen int var ok bool if len(msr.nonClosed) > maxSelectNum { var recv reflect.Value chosen, recv, ok = reflect.Select(msr.itemsCases) if ok { item := recv.Interface().(streamItem[T]) return item.chunk, item.err } msr.itemsCases[chosen].Chan = reflect.Value{} } else { var item *streamItem[T] chosen, item, ok = receiveN(msr.nonClosed, msr.sts) if ok { return item.chunk, item.err } }
// delete the closed stream for i := range msr.nonClosed { if msr.nonClosed[i] == chosen { msr.nonClosed = append(msr.nonClosed[:i], msr.nonClosed[i+1:]...) break } }
if len(msr.sourceReaderNames) > 0 { var t T return t, &SourceEOF{msr.sourceReaderNames[chosen]} } }
var t T return t, io.EOF}func receiveN[T any](chosenList []int, ss []*stream[T]) (int, *streamItem[T], bool) { return []func(chosenList []int, ss []*stream[T]) (index int, item *streamItem[T], ok bool){ nil, func(chosenList []int, ss []*stream[T]) (int, *streamItem[T], bool) { item, ok := <-ss[chosenList[0]].items return chosenList[0], &item, ok }, func(chosenList []int, ss []*stream[T]) (int, *streamItem[T], bool) { select { case item, ok := <-ss[chosenList[0]].items: return chosenList[0], &item, ok case item, ok := <-ss[chosenList[1]].items: return chosenList[1], &item, ok } }, func(chosenList []int, ss []*stream[T]) (int, *streamItem[T], bool) { select { case item, ok := <-ss[chosenList[0]].items: return chosenList[0], &item, ok case item, ok := <-ss[chosenList[1]].items: return chosenList[1], &item, ok case item, ok := <-ss[chosenList[2]].items: return chosenList[2], &item, ok } }, func(chosenList []int, ss []*stream[T]) (int, *streamItem[T], bool) { select { case item, ok := <-ss[chosenList[0]].items: return chosenList[0], &item, ok case item, ok := <-ss[chosenList[1]].items: return chosenList[1], &item, ok case item, ok := <-ss[chosenList[2]].items: return chosenList[2], &item, ok case item, ok := <-ss[chosenList[3]].items: return chosenList[3], &item, ok } }, func(chosenList []int, ss []*stream[T]) (int, *streamItem[T], bool) { select { case item, ok := <-ss[chosenList[0]].items: return chosenList[0], &item, ok case item, ok := <-ss[chosenList[1]].items: return chosenList[1], &item, ok case item, ok := <-ss[chosenList[2]].items: return chosenList[2], &item, ok case item, ok := <-ss[chosenList[3]].items: return chosenList[3], &item, ok case item, ok := <-ss[chosenList[4]].items:// … 省略 5 行;完整声明 L21–73,点击上方「浏览完整文件」const maxSelectNum = 5const maxSelectNum = 5为什么要分两条路?reflect.Select 灵活但慢;而绝大多数合并场景源都不多,于是 Eino 为 2~5 路手写了展开的 select,把反射开销省掉。这又是一次”常见路径手工优化、罕见路径才降级到反射”的取舍——和上一章类型检查里”编译期能拦就编译期拦”如出一辙。
还有一个变体 MergeNamedStreamReaders(schema/stream.go:990):当某一路 EOF 时,它吐一个 SourceEOF 告诉你是哪一路结束了,而不是默默咽下。多智能体里”等 A 说完再处理 B”就靠它。底层都汇进 InternalMergeNamedStreamReaders(schema/stream.go:1010)。
// MergeNamedStreamReaders merges multiple named StreamReaders into one.// Unlike [MergeStreamReaders], when a source stream reaches EOF the merged// reader emits a [SourceEOF] error (instead of silently continuing) so you can// detect exactly which source finished. Use [GetSourceName] to retrieve the// name from a SourceEOF error. The merged reader itself signals io.EOF only// after all named sources are exhausted.//// This is useful when downstream logic must react differently to each source// completing — for example, draining one agent's output before proceeding://// namedStreams := map[string]*schema.StreamReader[string]{// "agent_a": srA,// "agent_b": srB,// }// merged := schema.MergeNamedStreamReaders(namedStreams)// defer merged.Close()// for {// chunk, err := merged.Recv()// if errors.Is(err, io.EOF) { break }// if name, ok := schema.GetSourceName(err); ok {// fmt.Printf("%s finished\n", name)// continue// }// if err != nil { return err }// process(chunk)// }//// Returns nil if srs is empty.func MergeNamedStreamReaders[T any](srs map[string]*StreamReader[T]) *StreamReader[T] { if len(srs) < 1 { return nil }
ss := make([]*StreamReader[T], len(srs)) names := make([]string, len(srs))
i := 0 for name, sr := range srs { ss[i] = sr names[i] = name i++ }
return InternalMergeNamedStreamReaders(ss, names)}// InternalMergeNamedStreamReaders merges multiple readers with their names// into a single multi-stream reader.func InternalMergeNamedStreamReaders[T any](srs []*StreamReader[T], names []string) *StreamReader[T] { ss := make([]*stream[T], len(srs))
for i, sr := range srs { ss[i] = sr.toStream() }
msr := newMultiStreamReader(ss) msr.sourceReaderNames = names
return &StreamReader[T]{ typ: readerTypeMultiStream, msr: msr, }}装箱与拆箱:流与非流的边界
节点有的吐流、有的吐单值,编排引擎怎么抹平这个差异?靠一对互逆操作。
装箱:把一个已经算好的值伪装成流,用 StreamReaderFromArray(schema/stream.go:461)——它其实就是塞一个 arrayReader,Recv 时逐个吐出、最后 io.EOF。一个非流节点的输出,就这样被”装箱”成下游期待的流。
// StreamReaderFromArray creates a StreamReader from a given slice of elements.// It takes an array of type T and returns a pointer to a StreamReader[T].// This allows for streaming the elements of the array in a controlled manner.// eg.//// sr := schema.StreamReaderFromArray([]int{1, 2, 3})// defer sr.Close()//// for {// chunk, err := sr.Recv()// if errors.Is(err, io.EOF) {// break// }// if err != nil {// panic(err)// }// fmt.Println(chunk)// }func StreamReaderFromArray[T any](arr []T) *StreamReader[T] { return &StreamReader[T]{ar: &arrayReader[T]{arr: arr}, typ: readerTypeArray}}拆箱:反过来,把一个流收敛成单值,用 concatStreamReader(compose/stream_concat.go:50)——它把流里所有 chunk 收齐,交给 internal.ConcatItems(internal/concat.go:91)按类型拼接(map 合并、slice 拼接、或用你注册的 concat 函数)。大模型流式吐出的一堆 Message 分片,就是这样被拼回一条完整消息的。
func concatStreamReader[T any](sr *schema.StreamReader[T]) (T, error) { defer sr.Close()
var items []T
for { chunk, err := sr.Recv() if err != nil { if err == io.EOF { break }
if _, ok := schema.GetSourceName(err); ok { continue }
var t T return t, newStreamReadError(err) }
items = append(items, chunk) }
if len(items) == 0 { var t T return t, emptyStreamConcatErr }
if len(items) == 1 { return items[0], nil }
res, err := internal.ConcatItems(items) if err != nil { var t T return t, err } return res, nil}// ConcatItems the caller should ensure len(items) > 1func ConcatItems[T any](items []T) (T, error) { typ := generic.TypeOf[T]() v := reflect.ValueOf(items)
var cv reflect.Value var err error
// handle map kind if typ.Kind() == reflect.Map { cv, err = concatMaps(v) } else { cv, err = ConcatSliceValue(v) }
if err != nil { var t T return t, err }
return cv.Interface().(T), nil}装箱/拆箱是 Eino 处理”流与非流混排”的统一手法:引擎永远可以把任意节点的输出规整成下游想要的形态,使用者因此几乎感觉不到”这个节点到底是不是流式的”。
State:按类型逐级上溯的一条链
最后是状态。ADK 的记忆、跨节点共享数据,底层都落在 compose 的 state 上。
状态怎么存?图在运行时,如果注册了 WithGenLocalState(compose/generic_graph.go:37),就在 runCtx 里造一个 internalState 塞进 context(compose/graph.go:842)。关键在这个结构(compose/state.go:34)带一个 parent 指针:
// WithGenLocalState registers a function to generate per-run local state// that can be shared across nodes in the graph.func WithGenLocalState[S any](gls GenLocalState[S]) NewGraphOption { return func(ngo *newGraphOptions) { ngo.withState = func(ctx context.Context) any { return gls(ctx) } ngo.stateType = generic.TypeOf[S]() }}func (g *graph) compile(ctx context.Context, opt *graphCompileOptions) (*composableRunnable, error) { if g.buildError != nil { return nil, g.buildError }
// get run type runType := runTypePregel cb := pregelChannelBuilder if isChain(g.cmp) || isWorkflow(g.cmp) { if opt != nil && opt.nodeTriggerMode != "" { return nil, errors.New(fmt.Sprintf("%s doesn't support node trigger mode option", g.cmp)) } } if (opt != nil && opt.nodeTriggerMode == AllPredecessor) || isWorkflow(g.cmp) { runType = runTypeDAG cb = dagChannelBuilder }
// get eager type eager := false if isWorkflow(g.cmp) || runType == runTypeDAG { eager = true } if opt != nil && opt.eagerDisabled { eager = false }
if len(g.startNodes) == 0 { return nil, errors.New("start node not set") } if len(g.endNodes) == 0 { return nil, errors.New("end node not set") }
// toValidateMap isn't empty means there are nodes that cannot infer type for _, v := range g.toValidateMap { if len(v) > 0 { return nil, fmt.Errorf("some node's input or output types cannot be inferred: %v", g.toValidateMap) } }
for key := range g.fieldMappingRecords { // not allowed to map multiple fields to the same field toMap := make(map[string]bool) for _, mapping := range g.fieldMappingRecords[key] { if _, ok := toMap[mapping.to]; ok { return nil, fmt.Errorf("duplicate mapping target field: %s of node[%s]", mapping.to, key) }// … 省略 171 行;完整声明 L674–892,点击上方「浏览完整文件」type internalState struct { state any mu sync.Mutex parent *internalState // 指向外层图的 state}于是当图嵌套图(比如一个 ADK agent 内部又是一张图),state 就串成一条从内到外的链。你用 ProcessState[S](compose/state.go:165)读状态时,它调 getState[S](compose/state.go:175)沿着这条链逐级做类型断言:
// ProcessState processes the state from the context in a concurrency-safe way.// This is the recommended way to access and modify state in custom nodes.// The provided function handler will be executed with exclusive access to the state (protected by mutex).//// State Lookup Behavior:// - If the requested state type exists in the current graph, it will be returned// - If not found in current graph, ProcessState will search in parent graph states (for nested graphs)// - This enables nested graphs to access state from their parent graphs// - Follows lexical scoping: inner state of the same type shadows outer state//// Concurrency Safety:// - ProcessState automatically locks the mutex of the state being accessed (current or parent level)// - Each state level has its own mutex, allowing concurrent access to different levels// - The lock is held for the entire duration of the handler function//// Note: This method will report an error if the state type doesn't match or state is not found in the context chain.//// Example - Basic usage in a single graph://// lambdaFunc := func(ctx context.Context, in string, opts ...any) (string, error) {// err := compose.ProcessState[*MyState](ctx, func(ctx context.Context, state *MyState) error {// // Safely modify state// state.Count++// return nil// })// if err != nil {// return "", err// }// return in, nil// }//// Example - Nested graph accessing parent state://// // In an inner graph node// innerNode := func(ctx context.Context, input string) (string, error) {// // Access parent graph's state// err := compose.ProcessState[*OuterState](ctx, func(ctx context.Context, s *OuterState) error {// s.Counter++ // Safely modify parent state// return nil// })// if err != nil {// return "", err// }//// // Also access inner graph's own state// err = compose.ProcessState[*InnerState](ctx, func(ctx context.Context, s *InnerState) error {// s.Data = "processed"// return nil// … 省略 12 行;完整声明 L114–173,点击上方「浏览完整文件」for interState != nil { if cState, ok := interState.state.(S); ok { return cState, &interState.mu, nil // 找到类型匹配的那一层 } interState = interState.parent // 没匹配,上溯外层}这个设计很有意思:**状态不是按名字找,而是按类型找。**你在内层图里 ProcessState[*MyState],它会从最内层开始一路往外,找到第一个 state 类型正好是 *MyState 的那层图,返回它以及它那把独立的锁(每层一把 mu,compose/state.go:36)。这让”内层能读到外层共享的状态”变得自然,而无需任何全局注册表——类型本身就是寻址的钥匙。
type internalState struct { state any mu sync.Mutex parent *internalState}💡 动手
做个实验感受
Copy的惰性:Pipe出一个流,Copy(2),让其中一个 child 一口气读完,另一个隔一会儿再读。你会发现慢的那个仍能读到全部数据——因为快的那个把链表铺好了(schema/stream.go:837)。再把sync.Once的作用想一遍:如果去掉它,同一格会被两个 child 各读一次源头,数据就会错乱。
本章小结
- 流是读一次的管子,
StreamReader(schema/stream.go:168)是带 tag 的联合类型,五种来源对外统一,Recv按 tag 分派。 - Copy(
schema/stream.go:261)用sync.Once守护的读时复制单链表(schema/stream.go:784)做扇出:每格只从源头读一次,快者铺路慢者追赶,全 close 才关源。 - Merge(
schema/stream.go:912)按到达顺序交织;≤5 路走手写select,>5 路才用reflect.Select(阈值maxSelectNum=5,schema/select.go:19)。 - 装箱(
schema/stream.go:461)把值伪装成流,拆箱(compose/stream_concat.go:50→internal/concat.go:91)把流拼回值,抹平流与非流的边界。 - State(
compose/state.go:34)是一条带parent的链,ProcessState[S](compose/state.go:165)按类型逐级上溯,每层各有一把锁——类型即寻址。 - 设计钥匙:流与状态都奉行”最小机制、最强语义、成本后置”——这正是 ADK 流式与记忆的地基。
至此 Part IV 收官:你已经看清 compose 从执行内核、三个构建器到流与状态的全貌。下一部分我们把镜头拉远,回到全局——把 ADK、compose、components 三层如何咬合成一个完整的框架,做一次总览与延展。