Skip to content

Fix/sequential chain intermediate outputs#1476

Draft
ljluestc wants to merge 2 commits intotmc:mainfrom
ljluestc:fix/sequential-chain-intermediate-outputs
Draft

Fix/sequential chain intermediate outputs#1476
ljluestc wants to merge 2 commits intotmc:mainfrom
ljluestc:fix/sequential-chain-intermediate-outputs

Conversation

@ljluestc
Copy link
Copy Markdown

fix: accumulate intermediate outputs in SequentialChain

Fixes #1095

Problem

When using SequentialChain, intermediate chain outputs are lost and return nil. For example:

chain1 := chains.NewLLMChain(llm, prompt1)
chain1.OutputKey = "synopsis"

chain2 := chains.NewLLMChain(llm, prompt2)
chain2.OutputKey = "review"

seqChain, _ := chains.NewSequentialChain(
    []chains.Chain{chain1, chain2},
    []string{"title", "era"},
    []string{"synopsis", "review"},  // request both outputs
)

res, _ := chains.Call(ctx, seqChain, inputs)
fmt.Println(res["synopsis"]) // nil ← BUG
fmt.Println(res["review"])   // works

Root Cause

In SequentialChain.Call(), the line inputs = outputs replaces the entire accumulated state with only the current chain's output map:

// BEFORE (broken)
for _, chain := range c.chains {
    outputs, err = Call(ctx, chain, inputs, options...)
    if err != nil {
        return nil, err
    }
    inputs = outputs  // ← replaces ALL accumulated values
}
return outputs, nil

Execution trace:

  1. chain1 receives {title, era} → outputs {synopsis}
  2. inputs = {synopsis}title and era are lost
  3. chain2 receives {synopsis} → outputs {review}
  4. inputs = {review}synopsis is lost
  5. Returns {review} — synopsis is nil

This also means a later chain cannot reference an earlier (non-adjacent) chain's output. For example, if chain3 needed both title and review, it would fail with a missing input key error.

Fix

Accumulate all known values (original inputs + each chain's outputs) through the loop, then return only the declared outputKeys:

// AFTER (fixed)
func (c *SequentialChain) Call(ctx context.Context, inputs map[string]any, options ...ChainCallOption) (map[string]any, error) {
    knownValues := make(map[string]any, len(inputs))
    for k, v := range inputs {
        knownValues[k] = v
    }

    for _, chain := range c.chains {
        outputs, err := Call(ctx, chain, knownValues, options...)
        if err != nil {
            return nil, err
        }
        for k, v := range outputs {
            knownValues[k] = v
        }
    }

    result := make(map[string]any, len(c.outputKeys))
    for _, key := range c.outputKeys {
        result[key] = knownValues[key]
    }
    return result, nil
}

This is consistent with how Python LangChain's SequentialChain works — it accumulates all known variables and passes the full dict to each sub-chain.

Diff

--- a/chains/sequential.go
+++ b/chains/sequential.go
@@ -98,16 +98,26 @@
 // Call runs the logic of the chains and returns the outputs. This method should
 // not be called directly. Use rather the Call, Run or Predict functions that
 // handles the memory and other aspects of the chain.
 func (c *SequentialChain) Call(ctx context.Context, inputs map[string]any, options ...ChainCallOption) (map[string]any, error) {
-	var outputs map[string]any
-	var err error
+	knownValues := make(map[string]any, len(inputs))
+	for k, v := range inputs {
+		knownValues[k] = v
+	}
+
 	for _, chain := range c.chains {
-		outputs, err = Call(ctx, chain, inputs, options...)
+		outputs, err := Call(ctx, chain, knownValues, options...)
 		if err != nil {
 			return nil, err
 		}
-		// Set the input for the next chain to the output of the current chain
-		inputs = outputs
+		// Merge outputs into known values so subsequent chains
+		// and the final result can access all intermediate outputs.
+		for k, v := range outputs {
+			knownValues[k] = v
+		}
+	}
+
+	// Return only the declared output keys.
+	result := make(map[string]any, len(c.outputKeys))
+	for _, key := range c.outputKeys {
+		result[key] = knownValues[key]
 	}
-	return outputs, nil
+	return result, nil
 }

What Changed

How to Test

1. Build

go build ./chains/...

2. Run the new + existing tests

# Run all sequential chain tests
go test ./chains/ -v -count=1 -run "TestSequential" -timeout 30s

Expected output:

=== RUN   TestSequentialChain
--- PASS: TestSequentialChain
=== RUN   TestSequentialChainIntermediateOutputs
--- PASS: TestSequentialChainIntermediateOutputs
=== RUN   TestSequentialChainErrors
--- PASS: TestSequentialChainErrors

3. Manual verification

package main

import (
    "context"
    "fmt"
    "log"

    "github.com/tmc/langchaingo/chains"
    "github.com/tmc/langchaingo/llms/openai"
    "github.com/tmc/langchaingo/prompts"
)

func main() {
    llm, err := openai.New()
    if err != nil {
        log.Fatal(err)
    }

    chain1 := chains.NewLLMChain(llm, prompts.NewPromptTemplate(
        "Write a synopsis for {{.title}} set in {{.era}}", []string{"title", "era"},
    ))
    chain1.OutputKey = "synopsis"

    chain2 := chains.NewLLMChain(llm, prompts.NewPromptTemplate(
        "Review this synopsis: {{.synopsis}}", []string{"synopsis"},
    ))
    chain2.OutputKey = "review"

    // Include "synopsis" in outputKeys to access it in results
    seqChain, err := chains.NewSequentialChain(
        []chains.Chain{chain1, chain2},
        []string{"title", "era"},
        []string{"synopsis", "review"},
    )
    if err != nil {
        log.Fatal(err)
    }

    res, err := chains.Call(context.Background(), seqChain, map[string]any{
        "title": "Mystery in the haunted mansion",
        "era":   "1930s in Haiti",
    })
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("Synopsis:", res["synopsis"]) // ✅ now works (was nil before)
    fmt.Println("Review:", res["review"])      // ✅ works
}

4. Full test suite

go test ./chains/ -count=1 -timeout 60s

User Impact

  • Users who declare intermediate output keys in outputKeys will now correctly receive those values
  • Users who only declare the final output key see no behavior change
  • No breaking changes — the outputKeys contract is preserved

ljluestc and others added 2 commits February 16, 2026 19:37
SequentialChain.Call() was replacing the inputs map with each chain's
output (inputs = outputs), losing all intermediate values. This meant
intermediate outputs like 'synopsis' were nil when accessed from the
final result, even if declared in outputKeys.

The fix accumulates all known values (original inputs + each chain's
outputs) through the execution loop, then returns only the declared
outputKeys from the accumulated map.

Fixes tmc#1095

Co-Authored-By: ljluestc <ljluestc@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

how to get chain1.output in sequentialChainExample

1 participant