TT Lab
Get started
Learn Learning paths Courses

AI Agents — A Graph, Not a Model

One Branch Died and the Whole Answer Vanished

Continue in TT Lab

Goal

You scatter one question to several sources at the same time and gather it back. You confirm for yourself what exception arises when they write to the same slot at once and what decides the order of merging, and you build a branch whose number is decided at run time, a width limit, and partial success.

Why it matters

LangGraph executes in units of supersteps, so if several nodes can go in one superstep, those nodes run together. That is why adding branches takes only a few lines of edges. What is hard is merging — when several values arrive at once at the same key and there is no reducer, the graph stops with InvalidUpdateError. The bug that was silently overwriting in a graph that ran in order is exposed the moment it becomes parallel, so this exception is in fact a kind one. The order of merging is also easy to misunderstand. It is not "whichever finishes first comes first". In this lab you confirm that yourself by changing the names. If the sources are not fixed, you cannot draw the edges in advance. Send is an instruction: "run this node with this input", and that node runs as many times as the length of the list. And that node sees not the whole state but only the piece Send passed. Finally, if one branch throws an exception, the whole superstep dies and the results of branches that had already succeeded disappear too. If you return failure as a value, you can build partial success, and if you leave the name of the failed source, a partial result will not pretend to be the full result. The grader does not trust the explanations you wrote. It actually imports your module, runs it with arbitrary topics and arbitrary node names, and compares with values the grader computes separately.

Steps

  1. In /root/work/agpara/fanout.py, create SOURCES, hits, State, a node for each source, and build_graph(). It splits from START into as many branches as there are sources and goes to END. findings uses the appending reducer.
  2. Add conflict_demo(topic) so that it catches the exception that arises when two nodes write to a key with no reducer in the same superstep and returns it as {"error": ..., "message": ..., "note": ...}.
  3. Add merge_order(names) to confirm for yourself the order of merging while changing node names.
  4. Add a combine node and send the branches to combine instead of END. combine builds ranked (by hit count descending, ties by name ascending) and total.
  5. Add plan, fan_out, probe, build_dynamic() and search_dynamic(topic, picked) to build a branch whose number is decided at run time with Send.
  6. Add MAX_FANOUT = 2 and pick_sources(topic, limit=None) to put an upper limit on the width. Pick the sources with more hits first, and by name on ties.
  7. Add a failures key and run_partial(topic) so that even if one branch cannot find anything, the remaining answers survive.
  8. Record what you confirmed in /root/work/agpara/fanout_report.json and /root/work/agpara/fanout_report.md.

Notes

Search three places at once

In /root/work/agpara/fanout.py, create SOURCES, hits, State, a node for each source, and build_graph(). It splits from START into as many branches as there are sources and goes to END, and each node writes one result of its own to findings.

Building the branches is nothing more than calling add_edge(START, 이름) (the placeholder stands for the source name) once per source. What matters is attaching the appending reducer to findings — without it, the exception you will see in the next step shows up here first. Keep the node name the same as the source name but not overlapping with a state key.

Writing to the same slot at the same time stops it

Add conflict_demo(topic): build a small graph in which two nodes write in the same superstep to a key with no reducer, catch the exception that arises, and return it as {"error": 예외이름, "message": 예외메시지, "note": ""} (the placeholders stand for the exception name and the exception message).

It is langgraph.errors.InvalidUpdateError. Do not make up the message; put str(예외) (the string form of the exception) in as it is — it states which key is the problem. The point of this step is that in a graph that runs in order this would have been silently overwritten.

The order of merging is not chance

Add merge_order(names). Make nodes from those names, place them side by side, run once, and return the merged list of the appending key.

Try passing the same names in a different order. How the results come out is the answer to this step. When you make nodes in a loop, beware the trap where a lambda captures only the last name — bind the name with a default argument or a wrapping function.

Gather once after all the branches finish

Add a combine node and send the source nodes to combine instead of END. combine builds ranked (by hit count descending, ties by name ascending) and total (the sum of the hit counts).

If you send the branches to converge on the collecting node, that node runs only once after all the branches have finished. If you send each branch to END and set up a collecting node separately, you cannot tell when it runs. Breaking ties by name is to make the answer deterministic.

The number is decided at run time

Add plan, fan_out, probe, build_dynamic() and search_dynamic(topic, picked). fan_out returns a list of Sends, and probe looks only at the piece Send passed.

It is Send("노드이름", 넘길딕셔너리) (the placeholders stand for the node name and the dictionary to pass). For the third argument of the conditional edge, give the list of node names it can go to. Using state["source"] inside probe will feel unfamiliar, because what that node receives is not the whole state but what Send passed.

Put an upper limit on the width

Add MAX_FANOUT = 2 and pick_sources(topic, limit=None). Pick the sources with more hits first, by name ascending on ties, and use MAX_FANOUT if there is no limit. If it is 0 or below, it is an empty list.

At twenty sources, twenty calls go out at once. More important than setting the limit is that the rule for choosing must be deterministic. If you choose arbitrarily on ties, the same question gets different answers, and then you cannot compare yesterday and today.

Even if one fails, keep the rest

Add a failures key and run_partial(topic). For a topic it does not know, ticket does not throw an exception and returns {"failures": ["ticket"]}. The answer is {"ranked": [...], "total": 정수, "failures": [...정렬됨], "sources": 정수} (the placeholders stand for integers and the sorted list).

If one branch throws an exception, the whole superstep dies and the results of branches that had already succeeded disappear too. If you return failure as a value, the collecting node can know "two of the three found something and one failed". If you do not leave the name of the failed source, a partial result pretends to be the full result.

Record what you confirmed

Write conflict_error, merge_order, ranked, total, dynamic, picked and partial in /root/work/agpara/fanout_report.json, and write /root/work/agpara/fanout_report.md in four sections: ## 누가 같은 칸에 쓰는가 (who writes to the same slot), ## 합쳐지는 순서는 무엇으로 정해지나 (what decides the order of merging), ## 갯수가 실행 시점에 정해질 때 (when the number is decided at run time) and ## 하나가 실패하면 (when one fails).

merge_order holds both the names you passed and the order that came out as {"input": [...], "output": [...]}. ranked and total are the result of running the graph with the topic 환불 (the Korean topic meaning refund), dynamic is the answer of search_dynamic, picked is the answer of pick_sources('배송') (the topic meaning delivery), and partial holds only failures and sources from the answer of run_partial('쿠폰') (the topic meaning coupon). Get all of them by running.