算法艺术创作
algorithmic-art
通过代码和可调参数创作生成艺术与交互式视觉效果。
agentsop-state-reducer【设计与创意】Tool skill for declaring reducers on LangGraph state keys so parallel writes merge instead of crashing. Activates whenever a coder agent designs a StateGraph with parallel branches, fan-out via Send, multi-agent topologies, or whenever a run raises `InvalidUpdateError: At key '<k>': Can receive only one value per step`. Encodes the rule "every state …
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
InvalidUpdateError: At key '<k>': Can receive only one value per step. Encodes the rule "every state key is single-writer or
has a reducer — nothing in between."
version: 0.1.0Scope: a single decision — for each state key, declare a reducer or guarantee single-writer. Out of scope: checkpointers, HITL, supervisor vs. swarm — see
langgraph-sopfor those.
Activate when any trigger fires:
StateGraph, TypedDict/BaseModel schema,
or Annotated[..., <reducer>] typing.Send API,
multiple agents writing shared state, or a supervisor pattern where workers
return concurrently.langgraph.errors.InvalidUpdateError — message looks like
At key 'messages': Can receive only one value per step. Use an Annotated key to handle multiple values.Do not activate if every key is written by exactly one node per superstep (see §6: over-reducing single-writer keys is an anti-pattern).
LangGraph state is either single-writer or has a reducer. Nothing in between.
When a node returns {"k": v}, LangGraph must decide how to merge v into
existing state["k"]. There are exactly two legal regimes:
Annotated). At most one
node writes the key per superstep. The new value replaces the old.
Two concurrent writers → InvalidUpdateError.Annotated[T, reducer_fn]). Any number of writers may write per
superstep; LangGraph folds them via reducer_fn(current, new).The reducer is commutative-enough algebra that lets the engine schedule parallel writes without you reasoning about interleavings. Three canonical reducers cover ~90% of real graphs:
| Reducer | Type | Behaviour |
|---|---|---|
add_messages (from langgraph.graph.message) | list[BaseMessage] | Append; dedupe-and-update by message id (in-place edit when IDs match) |
operator.add | list, int, float, str | List concat / numeric sum |
Custom (curr, new) -> merged | anything | Domain-specific merge (keep-latest, dedupe-by-id, LLM-summarise) |
"Reducers are mandatory, not optional, for parallel execution." —
[cheatsheet/gotchas]
The add_messages quirk that beginners miss: it is not plain append. If
a new message shares an id with one in state, it overwrites in place. This
is what makes HITL interrupt() + edit-state work — a human can edit the
last AI turn and resume.
The decision is per-key, not per-schema. A schema can mix freely: one key
single-writer, another with add_messages, another with operator.add.
Run top-down for every new or modified state schema.
For each key k in the state schema, list every node that returns k in its
update dict. Be paranoid: include nodes spawned via Send, subgraphs whose
output schema overlaps the parent, and any conditional branches.
k: T.k: T will crash.Decision gate: if you cannot answer "which nodes write this key?" in one sentence per key, stop and redraw the graph before continuing.
Use the OP table in §4. Default ladder:
BaseMessage → add_messages.operator.add.operator.add.Before shipping, write a unit test that invokes the graph along the parallel
path with deterministic node outputs and asserts state matches expectation.
If the reducer is wrong (e.g., operator.add on dicts), this is where you
find out — not in production.
def test_parallel_merge():
out = graph.invoke({"items": []})
assert sorted(out["items"]) == ["a", "b"] # both workers' contributions present
In a docstring on the TypedDict / Pydantic model, note for each non-default key why it has a reducer. Future maintainers will thank you when they refactor.
Each op: Trigger → Action → Output.
add_messages for a chat history keymessages: list[BaseMessage] field; ≥1 of (HITL
edit-state, multi-agent that all append turns, ReAct loop).from typing import Annotated, TypedDict
from langchain_core.messages import AnyMessage
from langgraph.graph.message import add_messages
class S(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
operator.add for an accumulating listimport operator
from typing import Annotated, TypedDict
class S(TypedDict):
chunks: Annotated[list[str], operator.add]
operator.add for a counterretries: Annotated[int, operator.add]. Each node returns the
delta ({"retries": 1}), not the new total.{"retries": state["retries"]+1})
with operator.add double-counts. Always return deltas under operator.add.current_task: str.summary field; you want
whichever was generated more recently to win.from datetime import datetime
Summary = dict # {"text": str, "ts": datetime}
def keep_latest(curr: Summary | None, new: Summary) -> Summary:
if curr is None or new["ts"] >= curr["ts"]:
return new
return curr
class S(TypedDict):
summary: Annotated[Summary, keep_latest]
{"id": ..., ...}
documents and the union should be de-duplicated.def dedupe_by_id(curr: list[dict], new: list[dict]) -> list[dict]:
seen = {d["id"]: d for d in (curr or [])}
for d in new:
seen[d["id"]] = d # later wins on collision
return list(seen.values())
class S(TypedDict):
docs: Annotated[list[dict], dedupe_by_id]
InvalidUpdateError post-mortemInvalidUpdateError: At key '<k>': Can receive only one value per step. Use an Annotated key to handle multiple values.<k> in the schema, identify the concurrent
writers, then pick OP-1/2/3/5/6 based on key semantics.messages — what reducer?"{"messages": [AIMessage(...)]} in the same superstep when the
supervisor fans out. Without a reducer → InvalidUpdateError. Naive
operator.add works but loses HITL edit-in-place semantics.operator.add — it concatenates blindly; retried messages with
the same id would appear twice and break the chat UI.add_messages — explicitly designed for this. Append by default,
overwrite in place when id matches [lc-docs/messages].ids in each agent (e.g., uuid4()) so dedupe is
deterministic. If an agent retries, reuse the previous attempt's id.add_messages
is almost always the right answer — operator.add is a code smell on
message lists.summary — append-merge-or-replace?"summary
string for the same document, in parallel. With no reducer → crash. With
operator.add on strings → concatenation glues them edge-to-edge ("A.B."
not "A. B."). Neither is semantically right.def collect(curr: list[str] | None, new: str) -> list[str]:
return (curr or []) + [new]
class S(TypedDict):
summary_drafts: Annotated[list[str], collect]
summary: str # single-writer, set by merge_node
merge_node reads
state["summary_drafts"], runs one LLM call, writes
{"summary": "..."} (single-writer, no reducer needed).operator.add / a custom collector, then merge in a single-writer node
downstream. Reducers are for trivial folds; LLM merges belong in nodes.Send — do I still need a reducer?"[Send("worker", {"chunk": c}) for c in chunks].
Each worker writes {"results": [...]}. Schema is
results: list[dict] — does Send change reducer requirements?Send IS parallel writing. All workers complete in the same
superstep and merge back into parent state.Annotated[list[dict], operator.add] on results → crash on
the first run where >1 worker is spawned. (Schema looks fine in tests
with chunks=[c1] — fails in prod with chunks=[c1,c2].)results: Annotated[list[dict], operator.add].Send ≡ parallel writers, always. Any state key a
Send-target worker writes must have a reducer, even if the static graph
topology "looks" sequential.AP-1 · Ignoring the reducer until it crashes in prod. The schema looks
fine; tests pass with 1 worker; the first 2-chunk request in production
raises InvalidUpdateError. Fix: enumerate writers per key during
design (§3 Step 1), not after the page.
AP-2 · add_messages on non-message lists. It calls convert_to_messages
internally and will either raise or silently corrupt your data when items
aren't BaseMessage subclasses. Use operator.add or a custom reducer
for list[dict], list[str], list[Document].
AP-3 · Over-reducing single-writer keys. Declaring
Annotated[str, lambda a,b: b] "just in case" on a key only the supervisor
writes is noise — and worse, it hides future bugs by silently accepting
a second writer that should have raised. Leave single-writer keys
un-annotated; let the engine surface accidental concurrency.
AP-4 · Returning the new total under operator.add. With
retries: Annotated[int, operator.add], returning
{"retries": state["retries"] + 1} adds the new total to the old —
double-counting. Return the delta ({"retries": 1}).
AP-5 · Forgetting that Send is parallel. See Case 3. Any key written
by a Send-target is a parallel-write key.
AP-6 · Relying on reducer order. Reducers see writes in implementation-
defined order. add_messages and operator.add are order-insensitive for
the set of items but the resulting list order is not guaranteed across
runs. If you need a canonical order, sort downstream with an explicit key
(timestamp, branch index, etc.).
AP-7 · Stateful / non-pure reducers. Reducers must be pure functions
of (current, new). Reading external state (DB, time.now() inside the
reducer) breaks checkpoint replay and time-travel debugging. Push side
effects into nodes, never reducers.
AP-8 · Mutating curr in place inside a custom reducer. Return a new
value. Mutating the existing object can corrupt earlier checkpoints that
share the reference. Always construct and return a fresh container.
Hard boundary: reducers are a write-merge mechanism, not a read-consistency mechanism. If you need transactional read-modify-write across parallel nodes (e.g., "increment counter only if branch A succeeded"), that's a routing problem — sequence the nodes, don't smuggle it into a reducer.
The reducer pattern is not LangGraph's invention — it's a re-application of two well-known patterns. Recognizing the lineage helps onboard fast.
| Framework | Analog | Same idea | Different |
|---|---|---|---|
| LangGraph | Annotated[T, reducer] | Pure (curr, new) -> merged fold over parallel writes per superstep | Per-key declaration; runs inside a checkpointable state machine |
| Redux / Elm | reducer(state, action) -> state | Pure fold over an action stream, single source of truth | Single root reducer over a stream; LangGraph has per-key reducers over a superstep set |
| CRDTs (Conflict-Free Replicated Data Types) | G-Counter, OR-Set, LWW-Register | Commutative+associative merge of concurrent writes from distributed replicas | CRDTs assume eventually-consistent replicas; LangGraph reducers run inside one process at superstep boundaries — no network partitions, but the algebra rhymes |
| Pregel / BSP | message combiners between supersteps | Merge multiple incoming messages per vertex per superstep | LangGraph's direct ancestor — add_messages is literally a combiner |
| CrewAI | none (no shared typed state) | — | Task outputs flow sequentially via context; no parallel write merge to declare. That's why CrewAI is simpler — and why it can't express what LangGraph reducers express. |
| AutoGen | conversation log | Append-only message history | Single ordered log, not a typed multi-key state — no need for per-key reducers, but no parallel-write-on-typed-fields either |
The Redux insight that transfers cleanly: reducers must be pure, and that purity is what enables replay/time-travel. LangGraph's checkpoint replay relies on exactly this. If you've internalised Redux discipline, the LangGraph reducer rules are the same rules with a graph-shaped scope.
The CRDT insight that transfers cleanly: commutative merge frees you from
reasoning about order. operator.add on lists isn't strictly commutative
([a]+[b] != [b]+[a] as lists), but you should treat it as if it were and
sort downstream when order matters. Designing reducers to be
order-insensitive is the cheapest way to keep parallel graphs sane.
[cheatsheet/gotchas] = sumanmichael.github.io/langgraph-cheatsheet/cheatsheet/faqs-gotchas/
— "Reducers are mandatory, not optional, for parallel execution."[lc-docs/messages] = docs.langchain.com/oss/python/langgraph/ — add_messages "If a message with the same ID already exists, it updates that message in place rather than duplicating it."[aipractitioner/scaling] = aipractitioner.substack.com/p/scaling-langgraph-agents-parallelization
— parallel branch failure semantics.langgraph-sop for the full LangGraph SOP (state schema
design, checkpointers, HITL, multi-agent topologies). This skill is the
zoom-in on reducers only.