TT Lab
Get started
Learn Learning paths Courses

AI Agents — A Graph, Not a Model

Only One Footprint Is Left Behind

Continue in TT Lab

Goal

You design agent state not as a list of keys but as merge rules. After reproducing for yourself what happens when there is no reducer, you build four reducers (append, deduplicate, maximum, window trim), and split the input and output schemas to narrow the keys that go outside.

Why it matters

LangGraph's state is a structure in which each key has one channel. A node returns a dictionary containing only the keys it wants to change, and the graph applies it channel by channel. At that point, a key with no reducer is overwritten — that is why trace, written by three nodes in turn, keeps only the last entry. No error is raised, so it looks like "the log isn't kept", and you waste time suspecting the logging code. There is a problem on the other side too. Once you attach a reducer, that key grows without end, and the checkpointer saves the state in full. If a node runs thirty times, there are thirty checkpoints, and each one is the entire state at that point. So for a key that grows, you write the limit inside the reducer — if you scatter it across nodes, missing a single spot makes it leak silently. Finally, the state gets mixed with things that must not be visible from outside. If you decide the keys that go out through the output schema as an allowlist, new keys are blocked automatically even as they increase. The grader does not trust the explanations you wrote. It actually imports your module, pokes at the reducers with different values each time, and compares the state that comes out of running the graph with values the grader computes separately. Node names and numbers change with each run.

Steps

  1. In /root/work/agstate/state.py, create CATALOG, State, three nodes (intake, lookup, finish) and build_graph(). A node returns only the keys it changes, and keys it did not touch must stay as they were.
  2. Add overwrite_demo(names) so that it reproduces a key with no reducer being overwritten. For each name, make one node and chain them in a line, and each node writes just its own name to seen.
  3. Attach Annotated[list, operator.add] to trace in State so that the footprints of the three nodes accumulate in order.
  4. Create a reducer merge_sources(old, new) and attach it to the sources key. The same source remains only once and the order keeps the order first seen.
  5. Attach operator.add to used_calls and a keep_max(old, new) that you write yourself to peak_ms.
  6. Create RECENT_KEEP = 3 and a reducer keep_recent(old, new) so that the recent key keeps only the last three. finish writes two at a time.
  7. Create InputState and OutputState, and make build_public() return a graph compiled with StateGraph(State, input=InputState, output=OutputState). Only answer must appear in the result.
  8. Record the state design in /root/work/agstate/state_report.json and /root/work/agstate/state_report.md.

Notes

A node returns only the keys it changes

In /root/work/agstate/state.py, create CATALOG, State, three nodes (intake, lookup, finish) and build_graph(). build_graph() returns a compiled graph, and keys a node did not touch must stay as they were.

Define the state with TypedDict, add the nodes to StateGraph(State), and connect from START to END. A node returns not the whole state but a dictionary containing only the keys it wants to change — that way the values other nodes put in are not erased. If you add total=False, the keys do not all have to be filled in.

Without a reducer, only the last one remains

Add overwrite_demo(names). For each name in names, make one node and chain them in a line, and each node writes just its own name to the key seen, which has no reducer. The return value is {"writes": [...], "state_after": [...]}.

When you make nodes in a loop, beware the classic trap where a lambda captures only the last name — bind the name with a default argument or a wrapping function. In writes, put the node names you made in order, and in state_after, the seen value that actually remains after running the graph. This step deliberately reproduces a wrong design.

A reducer that accumulates footprints

Change trace in State to Annotated[list, operator.add] so that the footprints of the three nodes accumulate in the order ["intake", "lookup", "finish"].

You need from typing import Annotated and import operator. The function placed in the second slot of Annotated becomes that key's reducer. Leave the node code as it is — only one line of the state definition changes. That is the point of this design.

Do not write the same source twice

Create a reducer merge_sources(old, new) and attach it to the sources key. The same value remains only once and the order keeps the order first seen. The three nodes write ["질문"], ["질문", "재고목록"] and ["재고목록"] respectively (Korean words meaning "question" and "stock list").

A reducer is an ordinary function that takes two arguments (옛값, 새값) (the old value and the new value) and returns the new value. If you remove duplicates with set, the order collapses, so you have to filter while keeping the order. The old value may not exist yet, so write it so that it tolerates an empty value, like old or [].

What to add up and what to keep only the largest of

Attach operator.add to used_calls and a keep_max(old, new) that you write yourself to peak_ms. Each node writes 1 for used_calls, and for peak_ms writes intake 4, lookup 17 and finish 9.

You can attach reducers to numbers too. The point is to gather the counting rule into one line of the state definition instead of scattering it across nodes. You must not pass the built-in max directly as a reducer — LangGraph inspects the reducer's signature, and a built-in function has no signature, so it dies at compile. Wrap it one level.

Put a limit on a key that grows

Create RECENT_KEEP = 3 and a reducer keep_recent(old, new) so that the recent key keeps only the last three. intake writes ["intake"], lookup writes ["lookup"] and finish writes ["finish:작성", "finish:검토"] (the Korean words mean "draft" and "review").

Four come in and only three must remain, so the order matters: trim after merging. Put the limit in the reducer, not in the nodes — more nodes will be added in the future, and missing a single spot makes it leak silently. The fact that the checkpointer saves the state in full is the reason for this limit.

Send out only what should go out

Create InputState (the question only) and OutputState (the answer only), and make build_public() return a graph compiled with StateGraph(State, input=InputState, output=OutputState). The result of running it with only {"question": ...} must have just one key, answer.

Nodes still see the whole state, and only the entrance and the exit narrow. If you pull the code that connects the nodes out into a function, build_graph() and build_public() can share the same wiring. If you block with code that deletes what goes out, it falls behind whenever a new key is added — an allowlist blocks automatically.

Leave a record of why you decided that way

Write reducers, trace, sources, used_calls, peak_ms, recent_len and public_keys in /root/work/agstate/state_report.json, and write /root/work/agstate/state_report.md in four sections: ## 무엇을 상태에 두었나 (what you put in the state), ## 어떤 리듀서를 왜 붙였나 (which reducers you attached and why), ## 자라지 않게 막은 곳 (where you stopped growth) and ## 바깥에 내보내지 않는 것 (what you do not send outside).

Do not write the numbers by hand; fill them in with values you get by actually running your graph. reducers is an object with key names as keys and the names of the attached reducers as values (for example operator.add). public_keys is the list of keys that actually appeared in the result of build_public(). The grader computes the same things separately and compares.