Deep Dive into Agent Framework Internals: Architecture Design and Engineering Practice of LangChain, LangGraph, and Eino#

Introduction: The Inevitable Evolution from Concepts to Engineering

In the previous article, we systematically reviewed the core working patterns of AI Agents—from ReAct to multi-agent collaboration. However, between understanding these patterns and truly deploying them in production environments lies an engineering chasm: how do we translate patterns into runnable, stable, and observable systems through code?

This is precisely the purpose of frameworks. LangChain, LangGraph, and Eino—each offers a different engineering answer. LangChain is renowned for its “componentization,” LangGraph excels in “graph state machines,” and Eino provides an engineering-oriented orchestration system for the Go ecosystem. This article will delve into the underlying principles of all three from a source-code-level perspective, while revealing the engineering logic behind these designs—why were they designed this way? What problems do they solve? What trade-offs were made?


LangChain: From Component Library to Agent Engine#

Core Design Philosophy: Everything Is a Component#

LangChain’s design goal can be summarized as abstracting AI application development into three fundamental components—Models, Prompts, Chains—enabling developers to flexibly build, debug, and extend AI applications in a modular fashion.

Behind this design lies a clear engineering judgment: no matter how powerful large language models are, they are merely a “component” of an AI application. What truly makes an application run is the infrastructure built around the model—prompt management, tool invocation, memory storage, task orchestration, and more. LangChain componentizes all these capabilities, allowing developers to freely combine them like building blocks.

Engineering Logic: LangChain chose componentization over providing an all-in-one solution, rooted in the “long-tail” nature of AI application development—each business scenario has different requirements for models, prompts, and tools. Componentization sacrifices some end-to-end convenience in exchange for extreme flexibility and extensibility. This is also the fundamental reason why LangChain has become the “standard library for AI development.”


The Underlying Essence of Agent: A Graph with Only Two Nodes#

This is the most critical point for understanding LangChain Agent source code: the underlying architecture of LangChain 1.x Agents is actually a directed state graph (StateGraph) driven by LangGraph. The first import line in the create_agent source code is from langgraph.graph.state import StateGraph—LangChain here is merely a higher-level wrapper around LangGraph.

This graph has only two nodes:

# Core logic of create_agent (simplified from source)
def create_agent(model, tools, ...):
    # Create state graph with a single core field: messages (all conversation history)
    graph = StateGraph(AgentState)

    # Add model node: invoke LLM
    graph.add_node("model", model_node)

    # Add tools node: wrap all tools with LangGraph's built-in ToolNode
    tool_node = ToolNode(tools)
    graph.add_node("tools", tool_node)

    # Entry point: invoke model first
    graph.add_edge(START, "model")

    # Conditional edge: model → tools (when tool_calls present) or model → END (when no tool_calls)
    graph.add_conditional_edges(
        "model",
        _make_model_to_tools_edge(...),  # Condition: whether tool_calls is empty
        {"tools": "tools", END: END}
    )

    # tools → model (after tool execution, return to LLM for results)
    graph.add_edge("tools", "model")

    return graph.compile()

The execution logic of this graph is extremely simple and clear:

  • model node: When invoking the LLM, all tools’ JSON schemas are passed as parameters to the model. The model decides whether to answer directly (no tool_calls) or invoke a specific tool (returns tool_calls) based on conversation history
  • tools node: After receiving tool_calls from the LLM, it finds the corresponding real function by name in the registry, executes it, and wraps the return value into a ToolMessage
  • conditional edge: Checks whether AIMessage.tool_calls is empty—if not empty, goes to the tools node; if empty, ends

The graph engine jumps back and forth in this loop until the LLM decides to stop calling tools. This is the complete mechanism of LangChain Agent.

Engineering Logic: Why did LangChain ultimately choose to rewrite Agents with LangGraph instead of continuing to maintain AgentExecutor? The core reason is that AgentExecutor’s while loop has inherent deficiencies in loop control, state management, and error recovery. When an Agent needs to support HITL (Human-in-the-Loop) workflows like “pause → human approval → resume,” a while loop cannot take state snapshots and recover. LangGraph’s Pregel model, however, natively supports checkpointing at every step—this is precisely the engineering motivation behind LangChain’s switch to LangGraph as the Agent foundation.


Prompt Construction: “Soft Constraints” for Agent Behavior Control#

Prompt construction in LangChain Agent is the key link for controlling its behavior. From the source code, we can see that the Agent’s Prompt mainly consists of three parts:

# In ZeroShotAgent's from_llm_and_tools method
@classmethod
def from_llm_and_tools(cls, llm, tools, ...):
    prompt = cls.create_prompt(
        tools=tools,
        format_instructions=format_instructions,  # Format instructions: tell LLM how to output tool calls
        input_variables=input_variables,
    )
    llm_chain = LLMChain(llm=llm, prompt=prompt, ...)
    return cls(llm_chain=llm_chain, allowed_tools=tool_names, ...)

The key format instructions (FORMAT_INSTRUCTIONS) define the format specification for LLM tool call output—typically requiring JSON format with action and action_input fields.

Engineering Logic: This reveals a profound design constraint—Agent behavior is not hard-coded by code, but rather passed to the LLM through carefully designed Prompts as “behavioral guidelines.” This is a “soft constraint” design: the framework cannot force the LLM to respond in the expected output format, and can only increase the probability of format compliance through prompt engineering, with fallback handling when parsing fails. This design is a fundamental limitation of frameworks like LangChain, and also the engineering problem that LangGraph attempts to partially solve through “state machines”—shifting “control flow decisions” from Prompts to the code level.


AgentExecutor: The “Runtime” for Loop Execution (Early Architecture)#

In LangChain 1.x’s early architecture, AgentExecutor was the engine driving Agent execution, with a while loop at its core:

# Core loop logic of AgentExecutor (conceptual illustration)
def _call(self, inputs):
    # Build initial prompt, including tool descriptions and current task
    # Enter iteration loop
    while iterations < max_iterations:
        # 1. Get next action: invoke LLM, parse action
        action = self._get_next_action(inputs)

        # 2. If AgentFinish, LLM decided to answer directly, end loop
        if isinstance(action, AgentFinish):
            return action

        # 3. Execute tool: find and run from name_to_tool_map
        observation = self._execute_tool(action)

        # 4. Append observation to "scratchpad" (thinking draft area)
        #    Next LLM call will see this history
        inputs["agent_scratchpad"] += f"\n{action.log}\n{observation}"

This loop has two key limiting mechanisms:

  • max_iterations: prevents infinite loops
  • max_execution_time: prevents tasks from executing endlessly

Engineering Logic: AgentExecutor’s core design is an explicit control loop, not event-driven. This means:

  • Advantages: Execution flow is completely predictable, with every step clearly visible
  • Disadvantages: Cannot “pause” in the middle of the loop and wait for external input (e.g., human approval), nor recover from breakpoints after crashes

This is also the fundamental reason why the LangChain team abandoned AgentExecutor in subsequent versions and fully transitioned to LangGraph.


LangGraph: Architectural Elevation from “Chain” to “Graph”#

Why LangGraph?#

LangChain’s Chain is based on DAGs, naturally suited for linear task flows, but Agents require capabilities like loops, conditional rollback, branching, and state persistence. Implementing these logics with LangChain’s old AgentExecutor often leads to code that is difficult to maintain and debug.

LangGraph’s underlying runtime design directly addresses this problem.

Engineering Logic: LangGraph is not a replacement for LangChain, but an elevation of its core capabilities—from “chain” to “graph.” Their relationship is: LangGraph is the skeleton, LangChain is the muscle. The typical approach is to use LangGraph to define the agent’s overall flow (graph structure), while using LangChain’s components inside graph nodes to invoke models or process documents.


Core Runtime: Engineering the Pregel Model#

LangGraph’s runtime is named after Google’s Pregel algorithm—a method for describing effective large-scale parallel computation using graphs. Choosing Pregel as the foundation has profound engineering considerations: the BSP (Bulk Synchronous Parallel) model natively supports state snapshots, and state snapshots are the infrastructure for implementing HITL and fault-tolerant recovery.

In LangGraph’s Pregel implementation, execution is organized into multiple steps, each containing three phases:

  1. Plan: Determine which actors (nodes) to execute in this step. The first step selects actors subscribed to input channels; subsequent steps select actors subscribed to channels updated in the previous step
  2. Execution: Execute all selected actors in parallel until all complete, a failure occurs, or timeout is reached. During this phase, channel updates are not visible to actors—they can only be read in the next step
  3. Update: Update channels using values written by actors in this step
# Core structure of Pregel execution model (conceptual illustration)
class Pregel:
    def invoke(self, inputs):
        # Write inputs to input channels
        # Begin Step loop
        while True:
            # Plan: find all actors subscribed to updated channels
            actors = self._plan_next_actors()
            if not actors:
                break
            # Execution: execute all actors in parallel
            results = self._execute_parallel(actors)
            # Update: write results to channels
            self._update_channels(results)
        # Return output channel values
        return self._read_output_channels()

Engineering Logic: Three key engineering advantages of the Pregel model:

  • Natural Parallelism: All actors within the same Step can execute in parallel, significantly improving throughput
  • Natural Synchronization Between Steps: At the end of each Step, all channel updates are visible, with no race conditions
  • Step-Level Checkpointing: Taking state snapshots at Step boundaries is the foundation for fault tolerance and HITL

The trade-off is increased latency—the slowest parallel actor determines the entire Step’s completion time (synchronization barrier), plus additional overhead from state serialization.


Channels: The Core Abstraction for State Flow#

In LangGraph, actors (nodes) communicate through Channels. Each channel has a value type, update type, and update function:

Channel TypeBehaviorApplicable Scenarios
LastValueStores the last value sent to the channelDefault channel, suitable for input/output values or data passing from one step to the next
TopicConfigurable PubSub TopicSending multiple values between multiple actors, or accumulating outputs across multiple steps, with deduplication
BinaryOperatorAggregateAccumulates updates through binary operators (e.g., summation)Multi-step aggregation calculations

When using the StateGraph API, the graph structure defined by developers is automatically compiled into a Pregel instance—this means the object returned by graph.compile() is essentially a Pregel runtime.

Engineering Logic: LangGraph’s Channel mechanism answers a key question—how do nodes in a graph share and pass data?

LangChain’s old approach passed all data through a centralized “state dictionary,” with all nodes sharing the same mutable object. This introduces race conditions in concurrent scenarios, and leads to “state bloat” in long-running operations (all historical data piling up together, growing larger and larger).

LangGraph standardizes state flow through typed Channels:

  • Each Channel defines what type to write, how to merge writes, and who can subscribe
  • This essentially defines a data flow protocol at compile time, reducing the possibility of runtime errors
  • Meanwhile, Channel updates are incremental (only recording changes), not full snapshots, providing an efficiency foundation for the checkpointing mechanism

HITL Source Code Breakdown#

LangGraph implements HITL (Human-in-the-Loop) through a three-piece toolkit: interrupt() + checkpointer + Command(resume=...).

interrupt(): A “Replayable” Pause#

# Core logic of interrupt implementation (simplified from source)
def interrupt(value: Any) -> Any:
    scratchpad = conf[CONFIG_KEY_SCRATCHPAD]
    idx = scratchpad.interrupt_counter()  # Which interrupt in this node
    # ... Save interrupt information (node name, interrupt value, state, etc.) to checkpoint
    # Throw special exception to halt the graph engine
    raise GraphInterrupt(value)

When a node internally calls interrupt(value), LangGraph will:

  1. Record the current node, interrupt value, and execution state
  2. Persist the complete state (message history, variable values, current node) to storage through checkpointer
  3. Throw a GraphInterrupt exception to halt the entire graph

Recovery Execution Path:#

# Resume execution from external
# After human makes decision, feed back with Command(resume=...)
for event in graph.stream(
    Command(resume={"action": "approve", "value": "some_data"}),
    config={"configurable": {"thread_id": thread_id}}
):
    # Graph recovers from checkpoint and continues execution

During recovery, the framework reads the state from the checkpoint, continues execution from the line after the original interrupt call, and injects the resume data as the return value of interrupt().

This also explains why LangGraph requires configuring a checkpointer to use HITL—“no save, no load.”

Engineering Logic: The choice of HITL implementation reflects deep engineering trade-offs:

LangGraph chose to implement pausing through exceptions + checkpoints, rather than asynchronous events or coroutines. This means:

  • Advantages: Extremely simple implementation—interrupt() does not change any control flow, it simply throws an exception. All upper-level code does not need to be aware of the “pause”; it only needs to catch GraphInterrupt at the top of the exception handling chain
  • Trade-off: Pausing must occur at Step boundaries, not at any arbitrary position. This limits the granularity of pausing

The engineering judgment behind this choice is: Agent applications need “coarse-grained pausing” (pausing at the end of a step to wait for human input), not “fine-grained pausing” (pausing at any code line). The former is simple and reliable; the latter introduces enormous complexity (requiring process snapshot mechanisms similar to Erlang).


Eino: Engineering-Oriented Agent Framework Source Code Analysis for the Go Ecosystem#

Design Philosophy#

Eino (open-sourced by ByteDance) has as one of its core design philosophies building an Agent framework on top of Go’s strong type system. Unlike Python frameworks’ “runtime type checking,” Eino performs type validation at compile time.

// Compile-time type validation: validateFieldMapping checks mapping validity at compile time
func validateFieldMapping(predecessorType, successorType reflect.Type, mappings []*FieldMapping) error {
    // Check if types are assignable
    // Mismatch returns compile error directly, rather than runtime panic
}

This means that when orchestrating Graphs in Eino, type errors are exposed at compile time, rather than crashing at runtime in production.

Engineering Logic: The costs and benefits of Eino’s choice of compile-time type safety:

  • Benefits: Zero type panics in production. In the “inherently uncertain” domain of LLM applications, the framework eliminates uncertainty factors to the greatest extent, leaving developers to handle only the single uncertainty source of “LLM output uncertainty”
  • Costs: The framework implementation is relatively complex—requiring extensive use of Go generics and reflection to build type-safe graph orchestration APIs, while limiting certain dynamic capabilities (such as dynamically adding nodes at runtime)

Callback System: Context-Based “Event Bus”#

Eino’s callback system hides handler lists inside context, triggering them as context flows through each node—no global event bus, no registry, no reflection, only context.

// manager structure: handlers flow with context
type manager struct {
    globalHandlers []Handler  // Process-level global handlers
    handlers       []Handler  // Handlers registered for this call
    runInfo        *RunInfo   // Current component identity (type, name, component kind)
}

func ctxWithManager(ctx context.Context, m *manager) context.Context {
    return context.WithValue(ctx, CtxManagerKey{}, m)
}

func managerFromCtx(ctx context.Context) (*manager, bool) {
    v := ctx.Value(CtxManagerKey{})
    m, ok := v.(*manager)
    if ok && m != nil {
        n := *m        // ← Key: returns copy, not shared pointer
        return &n, true
    }
    return nil, false
}

Key Design: managerFromCtx returns a copy of *m (n := *m), not a pointer to the original struct. Each node reads an independent manager instance—no data races when Graph executes concurrently.

Two Registration Paths:

Registration MethodScopeApplicable Scenarios
AppendGlobalHandlersProcess-level, effective for all nodesOpenTelemetry tracing, global token metering
CtxWithHandlersThis call only, propagated through contextPer-request independent audit logs, session tracing

Engineering Logic: Eino’s Callback design reflects Go ecosystem engineering thinking:

  • Does not rely on global state (no global event bus), all observability data flows with the request
  • Uses copy isolation to avoid concurrent competition, rather than locks
  • Uses Context propagation rather than dependency injection, keeping code clean

The foundation of this design is Go’s Context mechanism—while not as flexible as Python’s asynchronous context managers, it is more reliable in high-concurrency scenarios.


Component Invocation Mechanism: Dual-Layer Design of CallOption#

Eino’s CallOption design distinguishes between two layers:

// Option structure: carries both abstraction-layer and implementation-layer options
type Option struct {
    // Abstraction-layer options: e.g., WithTemperature (uniformly defined by component abstraction interface)
    apply func(opts *Options)

    // Implementation-layer options: private configuration for specific implementations
    implSpecificOptFn any
}

This design both guarantees a unified configuration interface for all implementations (OpenAI, Claude, Ollama, etc.) (e.g., WithTemperature), and preserves private extension capabilities for specific implementations—a given implementation can define its own unique CallOption without affecting others.

Engineering Logic: The CallOption design solves the contradiction between “unified interface vs. implementation differences.”

In Python, this contradiction is typically resolved through kwargs passthrough or runtime type checking—neither safe nor elegant. Eino carries two layers of configuration through the Option structure, guaranteeing type safety at compile time while remaining flexible at runtime. This is an embodiment of Go’s strong type system + interface composition capabilities.


Branch Control and State Management#

Eino’s Graph supports single-choice branches, multi-choice branches, and streaming branches:

// Single-choice branch: returns next node key based on condition
branch := NewGraphBranch(
    func(ctx context.Context, in T) (string, error) {
        return "nodeA", nil
    },
    map[string]bool{"nodeA": true, "nodeB": true}
)

// Multi-choice branch: executes multiple branches in parallel
multiBranch := NewGraphMultiBranch(
    func(ctx context.Context, in T) (map[string]bool, error) {
        return map[string]bool{"nodeA": true, "nodeB": false}
    }
)

// Streaming branch: makes decision after reading only the first chunk
streamBranch := NewStreamGraphBranch(
    func(ctx context.Context, sr *schema.StreamReader[T]) (string, error) {
        first, _ := sr.Recv()
        return decideByFirst(first), nil
    }
)

For state management, Eino supports WithGenLocalState to create independent local states for each request, and StatePreHandler to preprocess state before node execution.

Engineering Logic: Eino’s branch design distinguishes three decision scenarios:

  • Single-choice: The most common if-else, only one path after decision
  • Multi-choice: Parallel processing, suitable for scenarios requiring simultaneous multiple paths
  • Streaming branch: Can make decisions without waiting for complete input—this significantly reduces latency in streaming processing scenarios

This fine-grained branch design indicates that Eino is not designed solely for “Agent conversations,” but for broader streaming data processing scenarios.


Eino’s HITL Implementation: Explicit Directed Recovery#

Similar to LangGraph, Eino also natively supports HITL, but adopts an explicit directed recovery strategy:

// Extract interrupt information
composeInfo, ok := compose.ExtractInterruptInfo(err)
// interruptContexts contains IDs and data for all interrupt points

// Directed recovery: specify interrupt ID and recovery data through ResumeWithData
resumeCtx := compose.ResumeWithData(context.Background(), interruptID, "user-provided recovery data")

// Or batch recover multiple interrupt points
resumeCtx := compose.BatchResumeWithData(ctx, map[string]any{
    "interrupt-id-1": "data1",
    "interrupt-id-2": 42,  // Any type
})

The recovered component can internally check for interrupted historical states through GetInterruptState, and retrieve user-provided recovery data through GetResumeContext.

Engineering Logic: The differences between Eino’s and LangGraph’s HITL implementations are worth deep analysis:

DimensionLangGraphEino
Interrupt Mechanisminterrupt() throws exceptionExtractInterruptInfo captures error
Recovery MethodCommand(resume=...)ResumeWithData(interruptID, data)
Semantics“Continue the interrupted graph”“Inject recovery data to specified interrupt point”
Applicable ScenariosSingle interrupt point recoveryBatch recovery of multiple interrupt points

Eino’s choice of explicit directed recovery over LangGraph’s implicit recovery is fundamentally rooted in Go’s error handling philosophy—Go discourages using exceptions for control flow, instead handling exceptions through explicit error returns. Eino’s ExtractInterruptInfo essentially extracts interrupt information from the returned error, then explicitly specifies the recovery target through ResumeWithData. This approach is more idiomatic in the Go ecosystem, but compared to LangGraph’s “continue execution from the interrupt line,” Eino requires developers to more explicitly manage interrupt IDs and recovery data.


Source-Level Horizontal Comparison of the Three Frameworks#

DimensionLangChainLangGraphEino
Core RuntimeAgentExecutor (while loop) → v1 migrated to LangGraphPregel state graph engine (BSP model)Chain/Graph/Workflow three-layer orchestration
Node ExecutionSequential executionParallel execution (parallel within Step, synchronized between Steps)Supports parallelism and branching
State ManagementImplicit (Memory component, mutable state)Explicit (State Schema + Channel + Checkpointer)Explicit (GenLocalState + StatePreHandler)
HITL ImplementationLimited supportinterrupt + checkpointer + Command(resume) (exception-driven)ExtractInterruptInfo + ResumeWithData (explicit error handling)
Type SafetyRuntime checking (Python dynamic characteristics)Runtime checkingCompile-time checking (Go generics + reflection)
Callback MechanismCallbackManager (global registration)Inherited from LangChainContext propagation + copy isolation + two registration paths
Core LanguagePythonPythonGo

Engineering Insights: Design Trade-offs from Source Code#

How Complex a Graph Does an Agent Really Need?#

From the source code level, the essence of LangChain Agent is a loop between two nodes (model ↔ tools). This fact reveals a key engineering judgment: for most business scenarios, Agents do not require complex graph structures.

LangGraph introduces rich graph capabilities (conditional edges, loops, parallelism, subgraphs), but at the cost of performance overhead (benchmarks show LangGraph average latency 10,155ms vs LangChain 6,046ms, approximately 68% slower) and cognitive complexity. Before deciding to introduce LangGraph, ask yourself: does my Agent really need pause/resume, human approval, or parallel execution? If the answer is no, LangChain’s create_agent is sufficient.


The “Physical Foundation” of State Persistence#

LangGraph’s interrupt+checkpointer and Eino’s ExtractInterruptInfo+ResumeWithData both revolve around state snapshots + directed recovery.

From an engineering perspective, HITL requires two infrastructure components:

  1. Serialization mechanism: Can serialize the complete state of graph execution (message history, variable values, current node position) into storage
  2. Recovery mechanism: Can read state from storage and continue execution from the original interruption point

LangGraph naturally provides serialization points through Pregel model Step boundaries; Eino achieves similar capabilities through explicit interrupt ID management. Both approaches converge to the same engineering fact: without persistent state management, there is no true HITL.


The Cost of Type Safety#

Eino validates types at compile time, trading for the certainty of “zero type panics in production”; LangGraph checks types at runtime, trading for the dynamic flexibility and rapid iteration capability of the Python ecosystem.

From an engineering selection perspective, this is not a question of “which is better,” but rather what your team values more:

  • Python + LangChain/LangGraph: Rapid prototyping, flexible iteration, but runtime crashes may occur due to type errors
  • Go + Eino: Compile-time safety, production stability, but longer development cycles and more framework constraints

Two Implementation Paradigms of Callback Mechanisms#

LangChain/LangGraph’s callback mechanism is based on global registration + event dispatch, while Eino’s callback mechanism is based on Context propagation + copy isolation.

Engineering differences between the two paradigms:

  • Global registration: Simple to deploy, but risks “callback hell” (multiple callbacks interfering with each other), and requires additional handling in concurrent scenarios
  • Context propagation: Each request is independent, naturally isolated, but requires developers to explicitly pass Context, making code slightly more verbose

LangChain’s choice is more suitable for “out-of-the-box” scenarios; Eino’s choice is more suitable for “large-scale concurrency” scenarios.


Conclusion#

LangChain, LangGraph, and Eino each answer different questions in Agent framework design. From the source code level, they all ultimately solve the same core contradiction: how to transform the LLM, this “unreliable inference engine,” into a “reliable system component” through engineering constraints and compensation.

  • LangChain achieved “soft constraints” through prompt engineering and loop runtime, lowering the barrier to AI applications
  • LangGraph achieved “stateful” execution through the Pregel model and checkpointing mechanisms, providing runtime guarantees for complex Agents
  • Eino achieved “compile-time safety” through Go’s type system and Context propagation mechanisms, bringing Go’s engineering determinism into the AI domain

Understanding the source code implementations of these frameworks is essentially understanding: every design decision corresponds to an engineering problem, and every engineering problem has its costs and benefits.

你又来啦!好开心 🐟