TL;DR
Eino's compose package offers three related APIs. Chain is a concise forward pipeline, Graph exposes explicit topology and cycles, and Workflow maps fields from multiple predecessors into typed inputs. All compile to a Runnable with Invoke, Stream, Collect, and Transform modes. Compilation checks structure and supported type alignment; production correctness still depends on runtime limits, authorization, cancellation, and outcome verification.
Key Takeaways
- Start with plain Go or Chain; move to Graph or Workflow only when topology or data mapping justifies it.
- Graph cycles require
WithMaxRunStepsplus an application-level termination and side-effect policy. - Workflow separates control flow from data flow and does not support cycles.
- Stream adaptation can buffer or copy data, so memory and cancellation behavior must be tested.
- A model-selected branch is a proposal. Deterministic code owns permissions and irreversible effects.
Three APIs, Three Different Jobs
The official overview and Workflow guide define the stable conceptual split:
| API | Best fit | Avoid when |
|---|---|---|
| Chain | A forward sequence with compatible node contracts | You need explicit branching, cycles, or complex fan-in |
| Graph | Branches, joins, subgraphs, or bounded cycles | A two-function pipeline would be clearer |
| Workflow | Acyclic control with field-level mappings from predecessors | You need a cycle such as a ReAct loop |
Chain is implemented as a simplified Graph builder. Workflow is not "Graph plus more features"; it makes a different ownership choice by separating execution ordering from field mapping.
Compile-Time and Runtime Boundaries
Eino's orchestration design principles use Go types to align neighboring nodes where possible. This catches a useful class of errors before traffic reaches the graph, but "compile" has two meanings:
| Check | Go compiler | Eino Compile |
Runtime |
|---|---|---|---|
| Generic function type is valid | Yes | No | No |
| Registered nodes and edges form a supported graph | No | Yes | No |
| Dynamic interface value has expected concrete type | Sometimes | Sometimes | Yes |
| Tool argument obeys business policy | No | No | Yes |
| Caller may access the object | No | No | Yes |
| Model answer is supported by evidence | No | No | Yes |
Treat Compile as topology construction, not a safety certificate.
Runnable Modes
A compiled Chain, Graph, or Workflow exposes four modes:
| Mode | Input | Output | Typical use |
|---|---|---|---|
Invoke |
Value | Value | Request-response |
Stream |
Value | Stream | Incremental output |
Collect |
Stream | Value | Aggregate input |
Transform |
Stream | Stream | Incremental processing |
The stream programming guide explains how Eino can box a value into a stream, concatenate a stream into a value, copy a stream for multiple consumers, or merge streams. Each conversion changes buffering and failure behavior. Test the exact node combination rather than assuming all four modes are equivalent in latency or memory.
Chain: Keep Straight Work Straight
Chain is appropriate when each result feeds the next step and no branch is required. A common pattern is:
validate input -> normalize data -> call model -> validate output
Do not place every concern in one Lambda. Provider calls, authorization, persistent effects, and pure transformations have different retry and observability contracts.
Graph: Make Branches and Cycles Explicit
Graph is appropriate when control flow itself matters. For a cycle, define:
- an exit condition independent of natural-language optimism;
WithMaxRunStepsas a hard safety valve;- deadline, cancellation, token, cost, and fan-out budgets;
- idempotency and reconciliation for effects;
- an explicit
outcome_unknownstate after ambiguous failures.
If a timeout occurs after a payment or message may have been committed, do not loop back and execute again. Query the authoritative downstream state first.
Workflow: Map Fields Without Inventing Glue Objects
Workflow can compose a downstream struct from selected fields of several predecessors and can separate control edges from data mappings. That is useful when existing business functions have different typed inputs. It is still an acyclic workflow, and field names are part of the compatibility contract.
When a mapped field carries sensitive or mutable data, record its source, revision, and authorization scope. Correct field wiring does not establish that the value is trustworthy.
Runnable Policy Graph
This example targets Eino Core v0.9.21. It builds a Graph with an explicit branch and a four-step runtime limit. The decision comes from trusted application data, not model text.
package main
import (
"context"
"fmt"
"strings"
"github.com/cloudwego/eino/compose"
)
type Job struct {
Task string
Approved bool
}
type Decision struct {
Status string
Task string
}
func main() {
ctx := context.Background()
graph := compose.NewGraph[Job, Decision]()
validate := compose.InvokableLambda(func(ctx context.Context, input Job) (Job, error) {
if err := ctx.Err(); err != nil {
return Job{}, err
}
input.Task = strings.TrimSpace(input.Task)
if input.Task == "" {
return Job{}, fmt.Errorf("task is required")
}
return input, nil
})
execute := compose.InvokableLambda(func(_ context.Context, input Job) (Decision, error) {
return Decision{Status: "executed", Task: input.Task}, nil
})
deny := compose.InvokableLambda(func(_ context.Context, input Job) (Decision, error) {
return Decision{Status: "denied", Task: input.Task}, nil
})
if err := graph.AddLambdaNode("validate", validate); err != nil {
panic(err)
}
if err := graph.AddLambdaNode("execute", execute); err != nil {
panic(err)
}
if err := graph.AddLambdaNode("deny", deny); err != nil {
panic(err)
}
if err := graph.AddEdge(compose.START, "validate"); err != nil {
panic(err)
}
branch := compose.NewGraphBranch(
func(_ context.Context, input Job) (string, error) {
if input.Approved {
return "execute", nil
}
return "deny", nil
},
map[string]bool{"execute": true, "deny": true},
)
if err := graph.AddBranch("validate", branch); err != nil {
panic(err)
}
if err := graph.AddEdge("execute", compose.END); err != nil {
panic(err)
}
if err := graph.AddEdge("deny", compose.END); err != nil {
panic(err)
}
runnable, err := graph.Compile(ctx, compose.WithMaxRunSteps(4))
if err != nil {
panic(err)
}
result, err := runnable.Invoke(ctx, Job{Task: "send invoice", Approved: false})
if err != nil {
panic(err)
}
fmt.Printf("%s: %s\n", result.Status, result.Task)
}
Expected output:
denied: send invoice
This graph deliberately keeps the approval value outside a model call. In a real service, derive it from an authenticated, durable approval record bound to the exact action and input revision.
Failure and Recovery Contract
Every node should classify failures:
| Failure class | Example | Default action |
|---|---|---|
| Invalid input | Missing field, unsupported enum | Fail closed; do not retry |
| Unauthorized | Wrong tenant or object | Deny; audit minimal metadata |
| Transient dependency | Rate limit or unavailable read | Bounded retry if idempotent |
| Ambiguous effect | Timeout after write request | Reconcile before retry |
| Budget exceeded | Steps, tokens, time, bytes | Stop with typed reason |
| Canceled | Client or parent context canceled | Propagate cancellation |
Graph state is not a universal transaction. A checkpoint can recover framework execution while external systems may already have changed.
Evaluation Checklist
- Compile the graph at startup and fail deployment on definition errors.
- Test every branch, join, cycle exit, maximum-step path, and cancellation point.
- Run Stream and Invoke separately; record buffer growth and first/last chunk behavior.
- Inject provider and Tool failures before and after possible side effects.
- Compare outputs against a plain Go baseline to justify orchestration complexity.
- Version graph topology, node implementations, options, prompts, and policy.
Frequently Asked Questions
Is Chain always simpler than Graph?
Only for truly sequential work. If branching is hidden inside one oversized Lambda, a small explicit Graph can be easier to test and operate.
Can Graph express a ReAct loop?
Yes, but Eino ADK already provides ChatModelAgent for the common model-tool loop. Build a custom cycle only when its behavior and evidence justify owning that complexity.
Does Workflow support branches?
Current Workflow documentation includes branch support, but Workflow remains acyclic and centers on field mappings. Verify the exact v0.9.21 API used by your design.
Is WithMaxRunSteps enough to control cost?
No. It bounds graph steps, not provider tokens, Tool fan-out, bytes, wall time, or external spend. Enforce each budget separately.
Can a Callback make a Graph observable?
It provides lifecycle hooks, but coverage, payload redaction, sampling, exporter failure, and stream errors still need explicit tests.
Primary Sources
- Eino
v0.9.21Chain source - Eino
v0.9.21Graph source - Eino orchestration design principles
- Eino Chain and Graph introduction
- Eino Workflow guide
- Go
contextpackage