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 WithMaxRunSteps plus 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.

flowchart LR Input --> Validate Validate -->|"allowed"| Execute Validate -->|"denied"| Reject Execute --> Result Reject --> Result

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:

text
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;
  • WithMaxRunSteps as a hard safety valve;
  • deadline, cancellation, token, cost, and fan-out budgets;
  • idempotency and reconciliation for effects;
  • an explicit outcome_unknown state 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.

go
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:

text
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