⚙️ LangGraph, and agents you can draw · LangGraph กับเอเจนต์ที่วาดออกมาเป็นรูปได้ · LangGraph:能画出来的智能体

A chain runs A then B. A graph can branch, loop, stop, resume, and hand work between agents — and you can see the shape of it, because you wrote the edges yourself. Seven files, all run on a local qwen2.5:14b, including the run where the model approved a tagline that broke the rule it had just been given, and the measurement showing the multi-agent version cost three times the tokens for a better answer. · เชนรัน A แล้วต่อ B ส่วนกราฟแตกกิ่งได้ วนลูปได้ หยุดได้ ทำต่อได้ และส่งงานต่อระหว่างเอเจนต์ได้ แล้วคุณยังมองเห็นรูปร่างของมัน เพราะคุณเขียนเส้นเชื่อมเอง เจ็ดไฟล์ รันจริงทั้งหมดบน qwen2.5:14b ในเครื่อง รวมถึงรอบที่โมเดลอนุมัติสโลแกนที่ผิดกฎซึ่งเพิ่งบอกมันไป และผลวัดที่แสดงว่าเวอร์ชันหลายเอเจนต์ใช้โทเคนสามเท่าเพื่อให้ได้คำตอบที่ดีกว่า · 链是先跑 A 再跑 B。图可以分支、循环、停下、继续,还能在多个智能体之间交接工作——而且你能看见它的形状,因为那些边是你自己连的。七个文件,全部在本地 qwen2.5:14b 上真跑过,包括模型批准了一条刚刚被告知规则却违反该规则的标语那一次,以及那份显示多智能体版本多花三倍 token 换来更好答案的实测。

🇬🇧 English

LangGraph is not an LLM library. It is a small state machine: you declare what the state looks like, write nodes that change it, and draw edges between them.

The first two files here have no model in them at all, on purpose. Everything people find confusing about agent frameworks is in those two files.

Then the model arrives, and its only job is to fill in state that an edge reads.

🇹🇭 ไทย

LangGraph ไม่ใช่ไลบรารี LLM มันคือเครื่องสถานะเล็ก ๆ คุณประกาศว่าสถานะหน้าตาเป็นยังไง เขียนโหนดที่เปลี่ยนมัน แล้วลากเส้นเชื่อมระหว่างโหนด

สองไฟล์แรกที่นี่ไม่มีโมเดลอยู่เลย และตั้งใจให้เป็นแบบนั้น ทุกอย่างที่คนงงเกี่ยวกับเฟรมเวิร์กเอเจนต์อยู่ในสองไฟล์นั้น

จากนั้นโมเดลค่อยเข้ามา และหน้าที่เดียวของมันคือเติมสถานะที่เส้นเชื่อมจะอ่าน

🇨🇳 中文

LangGraph 不是一个 LLM 库。它是一台小小的状态机:你声明状态长什么样,写一些改变它的节点,然后在节点之间连边。

这里头两个文件里根本没有模型,这是故意的。大家对智能体框架感到困惑的一切,都在那两个文件里。

然后模型才登场,而它唯一的工作,是把某条边要读的状态填上。

A chainเชน链A graphกราฟ图
Shapeรูปร่าง形状a line: A then B then Cเส้นตรง A แล้ว B แล้ว C一条线:A 然后 B 然后 Cedges you drew, including ones that go backเส้นเชื่อมที่คุณลากเอง รวมถึงเส้นที่ย้อนกลับ你自己连的边,包括往回连的
Branchingการแตกกิ่ง分支an if statement inside a stepคำสั่ง if ที่อยู่ในขั้นตอน某一步里的一个 ifa conditional edge, visible in the diagramเส้นเชื่อมมีเงื่อนไข มองเห็นได้ในแผนภาพ一条条件边,在图上看得见
Stopping halfwayหยุดกลางทาง中途停下start again from the topเริ่มใหม่ตั้งแต่ต้น从头再来一遍a checkpoint, resumed by thread idเช็กพอยต์ ทำต่อด้วย thread id一个检查点,用 thread id 接着跑
Two agentsสองเอเจนต์两个智能体a framework concept you adoptแนวคิดของเฟรมเวิร์กที่คุณต้องรับมาใช้一个你得接受的框架概念two nodes and a routing functionสองโหนดกับฟังก์ชันจัดเส้นทาง两个节点加一个路由函数
Measured hereวัดได้ที่นี่这里实测1 call, 134 tokens, 6.1 s1 ครั้ง 134 โทเคน 6.1 วิ1 次调用,134 token,6.1 秒4 calls, 408 tokens, 3.9 s4 ครั้ง 408 โทเคน 3.9 วิ4 次调用,408 token,3.9 秒
Statea TypedDictTypedDict一个 TypedDict
Nodestate in, dict outรับสถานะ คืน dict进状态,出 dict
Edgewho runs nextใครรันต่อ下一个谁跑
Reducermerge, not replaceรวม ไม่ใช่ทับ合并,不是覆盖
Checkpointstop and resumeหยุดแล้วทำต่อ停下再继续
EVERY FILE HERE WAS RUN, AND ONE OF THEM EMBARRASSES THE MODEL The outputs on this page are copied from real runs against a local qwen2.5:14b, not written by hand. That includes the ones that went badly: in Step 4 the model was told to approve a tagline only if it was five words or fewer with no colon, and it approved a six-word tagline containing a colon. In Step 5 a specialist told to answer only its own part answered somebody else's too. Those are the interesting parts. ผลลัพธ์ในหน้านี้คัดลอกมาจากการรันจริงกับ qwen2.5:14b ในเครื่อง ไม่ได้เขียนขึ้นเอง รวมถึงรอบที่ผลออกมาไม่ดีด้วย ในขั้นที่ 4 โมเดลถูกสั่งให้อนุมัติสโลแกนเฉพาะเมื่อมีห้าคำหรือน้อยกว่าและไม่มีเครื่องหมายทวิภาค แล้วมันก็อนุมัติสโลแกนหกคำที่มีทวิภาค ในขั้นที่ 5 ผู้เชี่ยวชาญที่ถูกสั่งให้ตอบเฉพาะส่วนของตัวเอง กลับไปตอบส่วนของคนอื่นด้วย นั่นแหละคือส่วนที่น่าสนใจ 这一页上的输出都是从对本地 qwen2.5:14b 的真实运行里复制来的,不是手写的。包括跑砸的那些:第 4 步里,模型被要求「只有在五个词以内且没有冒号时才批准」,然后它批准了一条六个词、带冒号的标语。第 5 步里,一个被要求只回答自己那部分的专家,把别人那部分也回答了。这些才是有意思的地方。

🧰 Step 0 — Install it, and point it at a model · ขั้นที่ 0 — ติดตั้ง แล้วชี้ไปที่โมเดล · 第 0 步 —— 装好它,并指向一个模型

YOU GET Everything the seven files need, and a free local model to run them against, so nothing on this page costs you anything. ทุกอย่างที่เจ็ดไฟล์นี้ต้องใช้ และโมเดลในเครื่องแบบฟรีไว้รันด้วย ดังนั้นไม่มีอะไรในหน้านี้ที่ทำให้คุณเสียเงิน 这七个文件需要的一切,外加一个免费的本地模型来跑它们,所以这一页上没有任何东西要你花钱。

💻 INSTALL

python -m venv C:\lg\venv          # Windows. Keep the path short.
C:\lg\venv\Scripts\activate

python3 -m venv ~/lg/venv          # macOS / Linux
source ~/lg/venv/bin/activate

pip install langgraph langchain-ollama
python -c "import importlib.metadata as m; print(m.version('langgraph'))"
# this page was written against langgraph 1.2.12 and langchain-ollama 1.1.0

💻 A MODEL TO TALK TO, FREE AND LOCAL

# Install Ollama from https://ollama.com, then pull the model:
ollama pull qwen2.5:14b            # 9 GB. llama3.1:8b works too, and is smaller.

# Check it answers:
curl http://127.0.0.1:11434/api/tags

# Every file below reads this, so a model on another machine needs only:
export OLLAMA_HOST=http://192.168.1.50:11434       # macOS / Linux
$env:OLLAMA_HOST = "http://192.168.1.50:11434"     # Windows PowerShell
THE MODEL CHOICE CHANGES THE ANSWERS, NOT THE LESSONS Everything here ran against qwen2.5:14b on a DGX Spark over the LAN. A smaller model will follow the routing instructions less reliably, which makes Step 3 look worse and Step 4 look the same, because Step 4 is about not trusting the model in the first place. If you have no GPU, use llama3.1:8b and expect the classifier to slip. ทุกอย่างที่นี่รันกับ qwen2.5:14b บน DGX Spark ผ่าน LAN โมเดลที่เล็กกว่าจะทำตามคำสั่งจัดเส้นทางได้แม่นยำน้อยลง ซึ่งทำให้ขั้นที่ 3 ดูแย่ลง แต่ขั้นที่ 4 จะเหมือนเดิม เพราะขั้นที่ 4 ว่าด้วยการไม่เชื่อโมเดลตั้งแต่แรกอยู่แล้ว ถ้าคุณไม่มี GPU ให้ใช้ llama3.1:8b และเตรียมใจว่าตัวจำแนกจะพลาดบ้าง 这里的一切都是在局域网内一台 DGX Spark 上的 qwen2.5:14b 上跑的。更小的模型执行路由指令会更不可靠,这会让第 3 步看起来更糟,而第 4 步看起来一样——因为第 4 步讲的本来就是别去信模型。如果你没有 GPU,用 llama3.1:8b,并且预期分类器会出错。

⚙️ Tutorial 1 — A graph is a state machine · บทที่ 1 — กราฟคือเครื่องสถานะ · 教程 1 —— 图就是状态机

🇬🇧 English

Two files, no model in either. If you understand these, the agent parts later are only prompts on top.

🇹🇭 ไทย

สองไฟล์ ไม่มีโมเดลทั้งคู่ ถ้าคุณเข้าใจสองอันนี้ ส่วนเอเจนต์ที่ตามมาก็เป็นแค่พรอมป์ที่วางทับอยู่ข้างบน

🇨🇳 中文

两个文件,都没有模型。如果你懂了这两个,后面那些智能体的部分不过是叠在上面的提示词而已。

1 One node, one piece of state · หนึ่งโหนด หนึ่งสถานะ · 一个节点,一份状态

YOU GET The whole vocabulary — state, node, edge, compile, invoke — in twenty lines that run without a model or a key. คำศัพท์ทั้งหมด คือ state, node, edge, compile, invoke ในยี่สิบบรรทัดที่รันได้โดยไม่ต้องมีโมเดลหรือคีย์ 全部词汇——state、node、edge、compile、invoke——就在二十行里,不需要模型也不需要密钥。

📄 graph1.py

"""The smallest graph there is: one node, one piece of state."""

from typing import TypedDict

from langgraph.graph import START, END, StateGraph


# The state is the only thing nodes can see or change. Declaring it as a
# TypedDict is not decoration: LangGraph reads these keys to work out what
# a node is allowed to write, so the class is required, not a style choice.
class State(TypedDict):
    topic: str
    note: str


# A node is a plain function. It takes the whole state and returns only the
# keys it wants to change.
def write_note(state: State) -> dict:
    return {"note": "Chiang Mai is the topic: " + state["topic"]}


builder = StateGraph(State)
builder.add_node("write_note", write_note)
builder.add_edge(START, "write_note")
builder.add_edge("write_note", END)

graph = builder.compile()

result = graph.invoke({"topic": "khao soi", "note": ""})

print("topic:", result["topic"])
print("note :", result["note"])

📋 WHAT IT PRINTS

topic: khao soi
note : Chiang Mai is the topic: khao soi
The lineบรรทัด这一行What it doesมันทำอะไร它在做什么
class State(TypedDict)The only channel between nodes. A node cannot pass an argument to the next node; it writes to the state and the next node reads it.ช่องทางเดียวระหว่างโหนด โหนดหนึ่งส่งอาร์กิวเมนต์ให้โหนดถัดไปไม่ได้ มันเขียนลงสถานะแล้วโหนดถัดไปอ่านเอา节点之间唯一的通道。一个节点没法给下一个节点传参数;它写进状态,下一个节点再读出来。
def write_note(state)A node is an ordinary function. Nothing is registered, decorated or inherited — that is the whole interface.โหนดคือฟังก์ชันธรรมดา ไม่มีการลงทะเบียน ไม่มีเดคอเรเตอร์ ไม่มีการสืบทอด นั่นคืออินเทอร์เฟซทั้งหมด节点就是一个普通函数。不用注册、不用装饰器、不用继承——接口就这些。
return {"note": ...}Return only what changed. Anything you leave out keeps its old value, which is why topic is still in the result.คืนเฉพาะสิ่งที่เปลี่ยน อะไรที่คุณไม่ใส่ก็คงค่าเดิมไว้ ซึ่งเป็นเหตุผลที่ topic ยังอยู่ในผลลัพธ์只返回改动的部分。你没写的东西保持原值,这就是为什么结果里 topic 还在。
add_edge(START, ...)START and END are the two built-in nodes. Forget the START edge and compiling fails rather than running an empty graph.START และ END คือโหนดสำเร็จรูปสองตัว ถ้าลืมเส้นเชื่อมจาก START การคอมไพล์จะล้มเหลว แทนที่จะรันกราฟเปล่าSTART 和 END 是两个内置节点。忘了连 START 那条边,编译就会失败,而不是跑一个空图。
builder.compile()Validates the shape once and hands back something runnable. Build it at import time, not inside your request handler.ตรวจรูปร่างครั้งเดียวแล้วคืนของที่รันได้กลับมา ให้สร้างตอน import ไม่ใช่ในตัวจัดการรีเควสต์把图的形状校验一次,交回一个可运行的东西。在 import 时构建,不要放在请求处理函数里。
YOUR TURN
  1. Delete the note key from State and run it. The error names the key, which is the check the TypedDict is buying you.
  2. Make write_note return {"topic": "something else"} as well, and watch a node rewrite its own input.
  1. ลบคีย์ note ออกจาก State แล้วรัน ข้อผิดพลาดจะระบุชื่อคีย์ ซึ่งคือการตรวจสอบที่ TypedDict ให้คุณ
  2. ให้ write_note คืน {"topic": "something else"} ด้วย แล้วดูโหนดเขียนทับอินพุตของตัวเอง
  1. 把 note 这个键从 State 里删掉再跑。报错会点名那个键,这正是 TypedDict 给你买到的检查。
  2. 让 write_note 也返回 {"topic": "something else"},看一个节点改写自己的输入。

2 The write that silently disappears · การเขียนที่หายไปเงียบ ๆ · 悄悄消失的那次写入

YOU GET The single most common LangGraph bug, shown twice in one run: the same graph with and without a reducer. บั๊ก LangGraph ที่พบบ่อยที่สุด แสดงสองครั้งในการรันเดียว คือกราฟเดียวกันแบบมีและไม่มี reducer 最常见的那个 LangGraph bug,在一次运行里演示两遍:同一个图,有 reducer 和没有 reducer。

📄 graph2.py

"""Two nodes in a row, and the one rule that surprises everybody:
a node's return value is merged into the state, and by default merging
a key means replacing it.
"""

from operator import add
from typing import Annotated, TypedDict

from langgraph.graph import START, END, StateGraph


class Replaces(TypedDict):
    steps: list[str]


# Annotated[..., add] tells LangGraph to combine the old value and the new
# one with add() instead of overwriting. For a list, add() is concatenation.
class Appends(TypedDict):
    steps: Annotated[list[str], add]


def chop(state) -> dict:
    return {"steps": ["chop the herbs"]}


def simmer(state) -> dict:
    return {"steps": ["simmer the broth"]}


def build(state_class):
    builder = StateGraph(state_class)
    builder.add_node("chop", chop)
    builder.add_node("simmer", simmer)
    builder.add_edge(START, "chop")
    builder.add_edge("chop", "simmer")
    builder.add_edge("simmer", END)
    return builder.compile()


without_reducer = build(Replaces).invoke({"steps": []})
with_reducer = build(Appends).invoke({"steps": []})

print("no reducer:", without_reducer["steps"])
print("with add  :", with_reducer["steps"])

📋 WHAT IT PRINTS

no reducer: ['simmer the broth']
with add  : ['chop the herbs', 'simmer the broth']
NOTHING WARNED YOU THAT "chop the herbs" WAS THROWN AWAY Both graphs ran cleanly. Both returned a list. One of them quietly lost a step, because writing to a key means replacing it unless you said otherwise. There is no warning, no error, and no clue in the output that anything went missing — you only notice when a list you expected to grow has one item in it. Any key that more than one node writes to needs a reducer, and in a multi-agent graph that is usually every interesting key. กราฟทั้งสองรันผ่านฉลุย ทั้งคู่คืนลิสต์กลับมา แต่อันหนึ่งทำขั้นตอนหายไปเงียบ ๆ เพราะการเขียนลงคีย์หมายถึงการแทนที่ ถ้าคุณไม่ได้บอกเป็นอย่างอื่น ไม่มีคำเตือน ไม่มีข้อผิดพลาด และไม่มีร่องรอยในเอาต์พุตว่ามีอะไรหายไป คุณจะสังเกตก็ต่อเมื่อลิสต์ที่คิดว่าจะยาวขึ้นมีอยู่รายการเดียว คีย์ไหนที่มีมากกว่าหนึ่งโหนดเขียนลงไปต้องมี reducer และในกราฟหลายเอเจนต์ นั่นมักจะเป็นทุกคีย์ที่น่าสนใจ 两个图都干净地跑完了。两个都返回了一个列表。其中一个悄悄丢了一步,因为写一个键就等于替换它,除非你另有说明。没有警告,没有报错,输出里也没有任何线索表明有东西不见了——你只有在一个本该变长的列表里只剩一项时才会发现。任何被超过一个节点写入的键都需要 reducer,而在多智能体的图里,那通常就是每一个有意思的键。
WHY IT WORKS THIS WAY LangGraph can run nodes in parallel. If two nodes finish at once and both write steps, something has to decide what the merged value is — and the framework refuses to guess. The reducer is you answering that question in advance. operator.add is the usual answer for lists; for a running total it would be the same function doing arithmetic, and for "last one wins" it is the default you already have. LangGraph รันโหนดขนานกันได้ ถ้าสองโหนดเสร็จพร้อมกันและเขียน steps ทั้งคู่ ต้องมีอะไรสักอย่างตัดสินว่าค่าที่รวมแล้วคืออะไร และเฟรมเวิร์กปฏิเสธที่จะเดา reducer คือคำตอบที่คุณเตรียมไว้ล่วงหน้า operator.add คือคำตอบปกติสำหรับลิสต์ ส่วนถ้าเป็นผลรวมสะสมก็เป็นฟังก์ชันเดียวกันที่ทำเลขคณิต และถ้าเป็นแบบ "อันหลังชนะ" ก็คือค่าเริ่มต้นที่คุณมีอยู่แล้ว LangGraph 可以并行跑节点。如果两个节点同时完成、又都写 steps,就必须有东西来决定合并后的值是什么——而框架拒绝去猜。reducer 就是你提前把这个问题回答掉。对列表来说 operator.add 是常规答案;如果是累加求和,就是同一个函数在做算术;而「后写的赢」就是你本来就有的默认行为。
YOUR TURN
  1. Add a third node and give it to both graphs. The gap between the two outputs widens by exactly one.
  2. Swap add for a function of your own, lambda old, new: new + old, and watch the order reverse.
  1. เพิ่มโหนดที่สามแล้วใส่ให้กราฟทั้งสอง ช่องว่างระหว่างสองเอาต์พุตจะกว้างขึ้นพอดีหนึ่งรายการ
  2. เปลี่ยน add เป็นฟังก์ชันของคุณเอง lambda old, new: new + old แล้วดูลำดับกลับด้าน
  1. 加第三个节点,两个图都加上。两份输出之间的差距正好扩大一项。
  2. 把 add 换成你自己的函数 lambda old, new: new + old,看顺序反过来。

🔀 Tutorial 2 — Letting the model steer · บทที่ 2 — ให้โมเดลเป็นคนบังคับทิศ · 教程 2 —— 让模型来掌方向

🇬🇧 English

The model never touches the graph. It writes a word into the state, and a function you wrote decides what that word means.

🇹🇭 ไทย

โมเดลไม่เคยแตะกราฟเลย มันเขียนคำหนึ่งลงในสถานะ แล้วฟังก์ชันที่คุณเขียนเป็นคนตัดสินว่าคำนั้นแปลว่าอะไร

🇨🇳 中文

模型从不碰这张图。它往状态里写一个词,然后由你写的函数来决定那个词是什么意思。

3 The model picks the branch · โมเดลเลือกกิ่ง · 模型来挑分支

YOU GET A conditional edge, and the defensive line of code that every one of them needs. เส้นเชื่อมมีเงื่อนไข และบรรทัดโค้ดป้องกันที่ทุกเส้นแบบนี้ต้องมี 一条条件边,以及每一条这样的边都需要的那行防御性代码。

📄 router.py

"""Let the model choose the branch.

A conditional edge is a function that looks at the state and returns the
name of the next node. Here the model fills in the state that function
reads, so the model is steering without ever touching the graph.
"""

import os
from typing import TypedDict

from langchain_ollama import ChatOllama
from langgraph.graph import START, END, StateGraph

OLLAMA = os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434")

# temperature=0 because this call is a decision, not writing. The same
# message should take the same branch every time you run it.
llm = ChatOllama(model="qwen2.5:14b", base_url=OLLAMA, temperature=0)

KINDS = ["booking", "complaint", "other"]


class State(TypedDict):
    message: str
    kind: str
    reply: str


def classify(state: State) -> dict:
    prompt = (
        "Classify this guesthouse message as exactly one word, "
        "either booking, complaint or other. Reply with the word alone.\n\n"
        + state["message"]
    )
    raw = llm.invoke(prompt).content.strip().lower()

    # The model returns a sentence whenever it feels like it, so narrow the
    # text down to one of the three words we can actually route on.
    kind = "other"
    for candidate in KINDS:
        if candidate in raw:
            kind = candidate
            break
    return {"kind": kind}


def route(state: State) -> str:
    return state["kind"]


def handle_booking(state: State) -> dict:
    return {"reply": "Checking the calendar for those dates."}


def handle_complaint(state: State) -> dict:
    return {"reply": "Sorry about that. The manager will call you today."}


def handle_other(state: State) -> dict:
    return {"reply": "Thanks for writing. Someone will read this shortly."}


builder = StateGraph(State)
builder.add_node("classify", classify)
builder.add_node("booking", handle_booking)
builder.add_node("complaint", handle_complaint)
builder.add_node("other", handle_other)

builder.add_edge(START, "classify")

# The dict maps what route() returns to the node that should run next.
builder.add_conditional_edges(
    "classify",
    route,
    {"booking": "booking", "complaint": "complaint", "other": "other"},
)

for node in ["booking", "complaint", "other"]:
    builder.add_edge(node, END)

graph = builder.compile()

messages = [
    "Do you have a room free on the 14th of March for two people?",
    "The shower in room 3 has been cold for two days now.",
    "What time does the night market start?",
]

for message in messages:
    result = graph.invoke({"message": message, "kind": "", "reply": ""})
    print(f"[{result['kind']:<9}] {result['reply']}")

📋 THREE MESSAGES, THREE BRANCHES

[booking  ] Checking the calendar for those dates.
[complaint] Sorry about that. The manager will call you today.
[other    ] Thanks for writing. Someone will read this shortly.
THE LOOP OVER KINDS IS THE IMPORTANT PART Asked for one word, the model will sometimes answer booking, sometimes Booking., and sometimes This looks like a booking request. All three are correct answers to the question and only the first is usable as a dictionary key. The loop turns any of them into one of three known strings, and falls back to other rather than raising. A routing function that crashes on an unexpected word will take your whole graph with it, on a Tuesday, because someone wrote a longer sentence. เมื่อถูกขอคำเดียว บางครั้งโมเดลตอบว่า booking บางครั้ง Booking. และบางครั้ง This looks like a booking request. ทั้งสามแบบเป็นคำตอบที่ถูกต้องของคำถามนั้น แต่มีแบบแรกเท่านั้นที่ใช้เป็นคีย์ของดิกชันนารีได้ ลูปนี้เปลี่ยนแบบไหนก็ได้ให้เป็นหนึ่งในสามสตริงที่รู้จัก และถอยไปที่ other แทนที่จะโยนข้อผิดพลาด ฟังก์ชันจัดเส้นทางที่พังเมื่อเจอคำที่ไม่คาดคิดจะลากกราฟทั้งอันของคุณล่มไปด้วย ในวันอังคารวันหนึ่ง เพราะมีคนเขียนประโยคยาวกว่าเดิม 当你要求只回一个词时,模型有时答 booking,有时答 Booking.,有时答 This looks like a booking request.。三种都是这个问题的正确答案,而只有第一种能当字典的键用。这个循环把其中任何一种都变成三个已知字符串之一,并且退回 other 而不是抛异常。一个遇到意外词就崩溃的路由函数,会在某个星期二把你整张图一起拖垮,就因为有人把句子写长了一点。
THE DICT IN add_conditional_edges IS NOT DECORATION You can omit it and let the return value be the node name directly. Keep it. With the mapping there, the compiler knows every branch your router can take, so a typo is caught when you build the graph. Without it, a router returning "bookings" fails at run time, halfway through a conversation. คุณจะละมันไว้แล้วให้ค่าที่คืนมาเป็นชื่อโหนดตรง ๆ ก็ได้ แต่ให้เก็บไว้ เมื่อมีแมปปิงอยู่ ตัวคอมไพล์จะรู้ทุกกิ่งที่ตัวจัดเส้นทางของคุณไปได้ ดังนั้นการพิมพ์ผิดจะถูกจับตอนสร้างกราฟ ถ้าไม่มีมัน ตัวจัดเส้นทางที่คืนค่า "bookings" จะพังตอนรัน กลางบทสนทนา 你可以不写它,直接让返回值就是节点名。留着它。有了这张映射表,编译器就知道你的路由器能走的每一条分支,于是拼写错误在你构建图的时候就被抓住。没有它,一个返回 "bookings" 的路由器会在运行时、在对话进行到一半时失败。
YOUR TURN
  1. Add a fourth kind, cancellation, to KINDS and the mapping, but forget to add the node. The compiler tells you before you ever run it.
  2. Delete the narrowing loop and set kind = raw. Then send a chatty message and read the traceback.
  1. เพิ่มชนิดที่สี่คือ cancellation ลงใน KINDS และในแมปปิง แต่ลืมเพิ่มโหนด ตัวคอมไพล์จะบอกคุณก่อนที่คุณจะได้รันด้วยซ้ำ
  2. ลบลูปที่ทำให้แคบลงออก แล้วตั้ง kind = raw จากนั้นส่งข้อความยาว ๆ เข้าไปแล้วอ่าน traceback
  1. 往 KINDS 和映射表里加第四种 cancellation,但故意不加节点。编译器会在你还没跑之前就告诉你。
  2. 删掉那个收窄的循环,改成 kind = raw。然后发一条啰嗦的消息,读那段 traceback。

4 A loop, and why it must not trust the model · ลูป และเหตุผลที่ห้ามเชื่อโมเดล · 循环,以及为什么不能信模型

YOU GET A write-and-critique loop, the same loop judged by four lines of Python instead, and the error you get when neither one stops. ลูปเขียนแล้ววิจารณ์ ลูปเดียวกันที่ตัดสินด้วยโค้ด Python สี่บรรทัดแทน และข้อผิดพลาดที่คุณจะได้เมื่อไม่มีอันไหนหยุดเลย 一个「写了再批」的循环,同一个循环改由四行 Python 来评判,以及两者都不停下时你会拿到的那个错误。

📄 loop.py

"""A loop is just an edge pointing backwards.

Two things this file is really about: how to write one, and why the exit
condition cannot be left to the model alone.
"""

import os
from typing import TypedDict

from langchain_ollama import ChatOllama
from langgraph.errors import GraphRecursionError
from langgraph.graph import START, END, StateGraph

OLLAMA = os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434")
llm = ChatOllama(model="qwen2.5:14b", base_url=OLLAMA, temperature=0)

MAX_ROUNDS = 3
RULE = "five words or fewer, and no colon"


def write(state) -> dict:
    prompt = "Write one short tagline for: " + state["brief"]
    if state["draft"]:
        prompt += (
            "\n\nYour previous attempt was: " + state["draft"]
            + "\nThe editor said: " + state["verdict"]
            + "\nWrite a better one. Reply with the tagline alone."
        )
    return {"draft": llm.invoke(prompt).content.strip(),
            "rounds": state["rounds"] + 1}


def critique_by_model(state) -> dict:
    prompt = (
        "You are a strict editor. Here is a tagline: " + state["draft"]
        + "\n\nReply with exactly OK only if it is " + RULE
        + ". Otherwise reply with one short sentence saying what to fix."
    )
    return {"verdict": llm.invoke(prompt).content.strip()}


def critique_by_rule(state) -> dict:
    # The rule is countable, so count it. A model asked to apply this
    # rule agreed to it and then ignored it; len() cannot.
    words = len(state["draft"].split())
    if words <= 5 and ":" not in state["draft"]:
        return {"verdict": "OK"}
    return {"verdict": f"{words} words, colon present: {':' in state['draft']}"}


def good_enough(state) -> str:
    # Two ways out, and the second is the one that saves you.
    if state["verdict"].upper().startswith("OK"):
        return "done"
    if state["rounds"] >= MAX_ROUNDS:
        return "done"
    return "again"


class State(TypedDict):
    brief: str
    draft: str
    verdict: str
    rounds: int


def build(critic):
    builder = StateGraph(State)
    builder.add_node("write", write)
    builder.add_node("critique", critic)
    builder.add_edge(START, "write")
    builder.add_edge("write", "critique")
    builder.add_conditional_edges(
        "critique", good_enough, {"again": "write", "done": END}
    )
    return builder.compile()


start = {"brief": "a quiet guesthouse in Chiang Mai",
         "draft": "", "verdict": "", "rounds": 0}

for name, critic in [("model", critique_by_model), ("rule", critique_by_rule)]:
    result = build(critic).invoke(start)
    print(f"--- critic: {name}")
    print("  rounds :", result["rounds"])
    print("  draft  :", result["draft"])
    print("  verdict:", result["verdict"][:60])

# Every graph also has a hard stop. recursion_limit counts supersteps, not
# loops, and hitting it raises rather than returning a half-finished state.
print()
try:
    build(critique_by_rule).invoke(start, {"recursion_limit": 2})
    print("finished inside the limit")
except GraphRecursionError as error:
    print("GraphRecursionError:", str(error)[:88])

📋 THE SAME BRIEF, TWO EDITORS

--- critic: model
  rounds : 1
  draft  : "Escape to Serenity: Chiang Mai's Cozy Hideaway"
  verdict: OK
--- critic: rule
  rounds : 3
  draft  : Serenity Amidst Chiang Mai's Charm: Your Quiet Escape
  verdict: 8 words, colon present: True

GraphRecursionError: Recursion limit of 2 reached without hitting a stop condition. You can increase the limi
READ THE FIRST BLOCK AGAIN. THE MODEL APPROVED SOMETHING THAT BREAKS ITS OWN RULE. The editor was told to reply OK only for a tagline of five words or fewer with no colon. It was shown "Escape to Serenity: Chiang Mai's Cozy Hideaway" — six words, and a colon — and replied OK. One round, approved, done. The identical draft judged by len(draft.split()) <= 5 and ":" not in draft was rejected correctly every time. This is not a bad model or a bad prompt; it is what asking a language model to apply a countable rule looks like at this size. บรรณาธิการถูกสั่งให้ตอบ OK เฉพาะกับสโลแกนที่มีห้าคำหรือน้อยกว่าและไม่มีเครื่องหมายทวิภาค มันเห็น "Escape to Serenity: Chiang Mai's Cozy Hideaway" ซึ่งมีหกคำและมีทวิภาค แล้วตอบว่า OK รอบเดียว อนุมัติ จบ ร่างเดียวกันที่ตัดสินด้วย len(draft.split()) <= 5 and ":" not in draft ถูกปฏิเสธอย่างถูกต้องทุกครั้ง นี่ไม่ใช่โมเดลแย่หรือพรอมป์แย่ แต่นี่คือหน้าตาของการขอให้โมเดลภาษาบังคับใช้กฎที่นับได้ ที่ขนาดเท่านี้ 编辑被要求只对五个词以内、且没有冒号的标语回 OK。它看到的是 "Escape to Serenity: Chiang Mai's Cozy Hideaway"——六个词,还有一个冒号——然后回了 OK。一轮,通过,收工。同一份草稿交给 len(draft.split()) <= 5 and ":" not in draft 来判,每次都被正确地否决。这不是模型差,也不是提示词差;这就是在这个尺寸上,让语言模型去执行一条可数的规则会长成的样子。
AND THE WRITER NEVER COMPLIED EITHER Look at the second block: three rounds, and the final draft is still eight words with a colon in it. The rule-based editor kept rejecting, correctly, and the writer kept failing to shorten. What ended that loop was not success — it was MAX_ROUNDS. Build the counter first and treat the model's approval as a bonus, because a loop whose only exit is the model agreeing with you is a loop with no exit. ดูบล็อกที่สอง สามรอบ และร่างสุดท้ายยังมีแปดคำและมีทวิภาคอยู่ดี บรรณาธิการแบบกฎปฏิเสธเรื่อย ๆ อย่างถูกต้อง และนักเขียนก็ยังทำให้สั้นลงไม่ได้สักที สิ่งที่จบลูปนั้นไม่ใช่ความสำเร็จ แต่คือ MAX_ROUNDS ให้สร้างตัวนับก่อน แล้วถือว่าการอนุมัติของโมเดลเป็นของแถม เพราะลูปที่มีทางออกทางเดียวคือโมเดลเห็นด้วยกับคุณ คือลูปที่ไม่มีทางออก 看第二块:三轮,最终草稿仍然是八个词,里面还有冒号。基于规则的编辑一直在正确地否决,而写手一直没能把它缩短。结束那个循环的不是成功,而是 MAX_ROUNDS。先把计数器写好,把模型的批准当成额外奖励,因为一个唯一出口是「模型同意你」的循环,就是一个没有出口的循环。
recursion_limit IS THE BACKSTOP, NOT THE PLAN Every graph has one, defaulting to 25. It counts supersteps rather than trips around your loop, so it does not map cleanly onto "three revisions" and it is not a substitute for your own counter. When it fires it raises GraphRecursionError and you lose the partial result, which is exactly what you do not want at the end of an expensive run. Use it to catch the bug where a router sends every state back to the same node; use MAX_ROUNDS to express how many revisions you actually wanted. ทุกกราฟมีค่านี้ ค่าเริ่มต้นคือ 25 มันนับ superstep ไม่ใช่จำนวนรอบที่วนในลูปของคุณ ดังนั้นมันจึงไม่แมปตรง ๆ กับคำว่า "แก้สามรอบ" และไม่ใช่ตัวแทนของตัวนับที่คุณเขียนเอง เมื่อมันทำงาน มันจะโยน GraphRecursionError และคุณจะเสียผลลัพธ์ที่ทำไปบางส่วน ซึ่งเป็นสิ่งที่คุณไม่อยากให้เกิดตอนจบการรันที่แพง ให้ใช้มันดักบั๊กแบบที่ตัวจัดเส้นทางส่งทุกสถานะกลับไปโหนดเดิม และใช้ MAX_ROUNDS เพื่อบอกว่าคุณอยากให้แก้กี่รอบจริง ๆ 每张图都有这个值,默认 25。它数的是 superstep,不是你在循环里转了几圈,所以它跟「改三次」并不能干净地对上,也不能替代你自己的计数器。它触发时会抛 GraphRecursionError,而你会丢掉已经跑出来的部分结果——这正是你在一次昂贵运行的末尾最不想要的。用它来抓「路由器把所有状态都送回同一个节点」这种 bug;用 MAX_ROUNDS 来表达你真正想要几轮修改。
YOUR TURN
  1. Raise MAX_ROUNDS to 8 and run the rule editor again. Does the writer ever get under five words?
  2. Delete the rounds check from good_enough so only the editor can stop it, and see which limit catches you.
  1. เพิ่ม MAX_ROUNDS เป็น 8 แล้วรันบรรณาธิการแบบกฎอีกครั้ง นักเขียนลงมาต่ำกว่าห้าคำได้ไหม
  2. ลบการเช็ก rounds ออกจาก good_enough ให้มีแค่บรรณาธิการที่หยุดได้ แล้วดูว่าขีดจำกัดตัวไหนมารับคุณ
  1. 把 MAX_ROUNDS 提到 8,再跑一次规则编辑。写手最后能压到五个词以内吗?
  2. 把 good_enough 里的 rounds 判断删掉,只留编辑能喊停,看看最后是哪一个上限接住了你。

👥 Tutorial 3 — Multi-agent, which is not a new idea · บทที่ 3 — หลายเอเจนต์ ซึ่งไม่ใช่เรื่องใหม่ · 教程 3 —— 多智能体,其实不是新东西

🇬🇧 English

An agent is a node with its own prompt. A supervisor is a node that writes a name into the state. A handoff is a conditional edge reading that name.

You already built all three in Tutorial 2. Nothing new is imported below.

🇹🇭 ไทย

เอเจนต์คือโหนดที่มีพรอมป์ของตัวเอง ผู้ควบคุมคือโหนดที่เขียนชื่อลงในสถานะ การส่งงานต่อคือเส้นเชื่อมมีเงื่อนไขที่อ่านชื่อนั้น

คุณสร้างครบทั้งสามอย่างไปแล้วในบทที่ 2 ข้างล่างนี้ไม่มีการ import อะไรใหม่เลย

🇨🇳 中文

智能体就是一个带自己提示词的节点。主管就是一个把名字写进状态的节点。交接就是一条读那个名字的条件边。

这三样你在教程 2 里已经全建过了。下面没有 import 任何新东西。

5 A supervisor and two specialists · ผู้ควบคุมกับผู้เชี่ยวชาญสองคน · 一个主管,两个专家

YOU GET The supervisor pattern in one file, and a look at what the specialists actually did with their instructions. แพตเทิร์นผู้ควบคุมในไฟล์เดียว และการดูว่าผู้เชี่ยวชาญทำอะไรกับคำสั่งที่ได้รับจริง ๆ 一个文件里的主管模式,以及看看那些专家到底怎么对待给它们的指令。

📄 supervisor.py

"""Multi-agent, which in LangGraph is not a new concept at all.

An agent is a node with its own prompt. A supervisor is a node that
writes the name of the next one into the state. The handoff everybody
talks about is a conditional edge reading that name.
"""

import os
from operator import add
from typing import Annotated, TypedDict

from langchain_ollama import ChatOllama
from langgraph.graph import START, END, StateGraph

OLLAMA = os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434")
llm = ChatOllama(model="qwen2.5:14b", base_url=OLLAMA, temperature=0)

MAX_TURNS = 4

SPECIALISTS = {
    "transport": "You handle getting around Chiang Mai: airport, songthaews, grab.",
    "food": "You handle eating in Chiang Mai: dishes, markets, what to order.",
}


class State(TypedDict):
    request: str
    # Every specialist appends, so this key needs a reducer or each one
    # would wipe out the answer before it.
    answers: Annotated[list[str], add]
    done: Annotated[list[str], add]
    next: str
    turns: int


def supervisor(state: State) -> dict:
    left = [name for name in SPECIALISTS if name not in state["done"]]
    if not left or state["turns"] >= MAX_TURNS:
        return {"next": "finish", "turns": state["turns"] + 1}

    prompt = (
        "A guest wrote: " + state["request"]
        + "\n\nWhich specialist should answer next? Choose one of: "
        + ", ".join(left)
        + ". Reply with the name alone."
    )
    choice = llm.invoke(prompt).content.strip().lower()

    # Same narrowing as the router: the model is choosing, not writing.
    picked = left[0]
    for name in left:
        if name in choice:
            picked = name
            break
    return {"next": picked, "turns": state["turns"] + 1}


def make_specialist(name: str):
    def specialist(state: State) -> dict:
        prompt = (
            SPECIALISTS[name]
            + "\nAnswer only the part of this you are responsible for, "
            "in one sentence.\n\n" + state["request"]
        )
        answer = llm.invoke(prompt).content.strip()
        return {"answers": [f"{name}: {answer}"], "done": [name]}
    return specialist


def route(state: State) -> str:
    return state["next"]


builder = StateGraph(State)
builder.add_node("supervisor", supervisor)
for name in SPECIALISTS:
    builder.add_node(name, make_specialist(name))

builder.add_edge(START, "supervisor")
builder.add_conditional_edges(
    "supervisor", route,
    {"transport": "transport", "food": "food", "finish": END},
)
# Each specialist hands control back, which is what makes it a supervisor
# rather than a fixed pipeline.
for name in SPECIALISTS:
    builder.add_edge(name, "supervisor")

graph = builder.compile()

result = graph.invoke({
    "request": "We land Friday night and want something local to eat. "
               "How do we get in from the airport, and what should we order?",
    "answers": [], "done": [], "next": "", "turns": 0,
})

print("supervisor turns:", result["turns"])
print("specialists used:", result["done"])
for answer in result["answers"]:
    print(" -", answer[:110])

📋 ONE COMPOUND QUESTION, TWO SPECIALISTS

supervisor turns: 3
specialists used: ['transport', 'food']
 - transport: From the airport, take a songthaew or Grab car into Chiang Mai and look for a local restaurant serv
 - food: From the airport, take a taxi or Grab car into Chiang Mai city, and once there, try ordering some Khao S
BOTH SPECIALISTS ANSWERED THE TRANSPORT QUESTION Each one was told to answer only the part of this you are responsible for. The transport specialist did its job. The food specialist began "From the airport, take a taxi or Grab car into Chiang Mai city…" before getting to the khao soi. You paid for an airport answer twice, and in a longer pipeline the two versions would contradict each other somewhere and you would have to decide which to believe. Specialists do not stay in their lane because you asked them to; the usual fix is to stop passing the whole request to everybody and give each one only the slice it owns. แต่ละคนถูกสั่งให้ตอบ เฉพาะส่วนที่ตัวเองรับผิดชอบ ผู้เชี่ยวชาญด้านการเดินทางทำหน้าที่ของตัวเอง ส่วนผู้เชี่ยวชาญด้านอาหารเริ่มด้วย "From the airport, take a taxi or Grab car into Chiang Mai city…" ก่อนจะไปถึงข้าวซอย คุณจ่ายค่าคำตอบเรื่องสนามบินสองครั้ง และในไปป์ไลน์ที่ยาวกว่านี้ สองเวอร์ชันนั้นจะขัดแย้งกันเองที่ไหนสักแห่ง แล้วคุณจะต้องตัดสินว่าจะเชื่ออันไหน ผู้เชี่ยวชาญไม่ได้อยู่ในเลนของตัวเองเพราะคุณขอ ทางแก้ตามปกติคือเลิกส่งคำขอทั้งก้อนให้ทุกคน แล้วให้แต่ละคนได้เฉพาะส่วนที่เป็นของตัวเอง 每一个都被告知只回答你负责的那部分。交通专家做到了。美食专家的开头是 "From the airport, take a taxi or Grab car into Chiang Mai city…",然后才说到 khao soi。你为机场那个答案付了两次钱,而在更长的流水线里,这两个版本会在某处互相矛盾,然后你得决定信哪个。专家不会因为你请求了就守在自己的车道里;常见的修法是别再把整条请求发给所有人,而是只给每个人它负责的那一片。
WHAT A HANDOFF ACTUALLY IS There is no message being passed. Every specialist edge goes back to supervisor, and the only thing that travels is the state: the answers list grew, the done list grew, and the supervisor reads done to see who is left. Both of those keys carry Annotated[list, add] for exactly the reason Step 2 showed — two nodes write them, so without the reducer the second specialist would erase the first one's answer and the supervisor would loop forever believing nobody had been. ไม่มีข้อความอะไรถูกส่งต่อเลย เส้นเชื่อมของผู้เชี่ยวชาญทุกเส้นย้อนกลับไปที่ supervisor และสิ่งเดียวที่เดินทางคือสถานะ ลิสต์ answers ยาวขึ้น ลิสต์ done ยาวขึ้น และผู้ควบคุมอ่าน done เพื่อดูว่าเหลือใคร คีย์ทั้งสองมี Annotated[list, add] ด้วยเหตุผลเดียวกับที่ขั้นที่ 2 แสดงไว้เป๊ะ ๆ คือมีสองโหนดเขียนลงไป ดังนั้นถ้าไม่มี reducer ผู้เชี่ยวชาญคนที่สองจะลบคำตอบของคนแรกทิ้ง และผู้ควบคุมจะวนไม่รู้จบเพราะเชื่อว่ายังไม่มีใครไปเลย 根本没有什么消息在传递。每条专家的边都连回 supervisor,唯一在走的东西是状态:answers 列表变长了,done 列表变长了,而主管读 done 来看还剩谁。这两个键都带着 Annotated[list, add],原因正是第 2 步演示过的那个——有两个节点在写它们,所以没有 reducer 的话,第二个专家会把第一个的答案擦掉,而主管会因为以为没人去过而永远循环下去。
YOUR TURN
  1. Remove Annotated[list[str], add] from done and run it. You have just built the infinite loop that MAX_TURNS exists to stop.
  2. Add a third specialist for temples. Does the supervisor still pick sensibly when the request never mentions one?
  1. เอา Annotated[list[str], add] ออกจาก done แล้วรัน คุณเพิ่งสร้างลูปไม่รู้จบที่ MAX_TURNS มีไว้เพื่อหยุดมัน
  2. เพิ่มผู้เชี่ยวชาญคนที่สามเรื่องวัด ผู้ควบคุมยังเลือกได้สมเหตุสมผลไหมเมื่อคำขอไม่ได้พูดถึงวัดเลย
  1. 把 done 上的 Annotated[list[str], add] 去掉再跑。你刚刚造出了那个 MAX_TURNS 专门用来阻止的无限循环。
  2. 加第三个专家负责寺庙。当请求里压根没提到寺庙时,主管还挑得合理吗?

💾 Tutorial 4 — Memory between runs · บทที่ 4 — ความจำข้ามการรัน · 教程 4 —— 跨次运行的记忆

🇬🇧 English

The feature that is genuinely hard to add yourself, and the one that makes a graph worth the ceremony.

🇹🇭 ไทย

ฟีเจอร์ที่เพิ่มเองยากจริง ๆ และเป็นตัวที่ทำให้กราฟคุ้มกับพิธีรีตองทั้งหมด

🇨🇳 中文

真正很难自己加上去的那个特性,也是让这套繁文缛节值回票价的那个。

6 Checkpoints and thread ids · เช็กพอยต์และ thread id · 检查点与 thread id

YOU GET State that survives between calls, separate conversations from one graph object, and a queryable history you can debug with. สถานะที่อยู่รอดข้ามการเรียก บทสนทนาที่แยกกันจากกราฟอ็อบเจกต์เดียว และประวัติที่ค้นได้ไว้ดีบัก 能在多次调用之间存活的状态、从同一个图对象分出来的不同对话,以及一份可以查询、拿来调试的历史。

📄 memory.py

"""The feature that is hard to bolt on afterwards: the graph can stop,
and start again later knowing what happened.

A checkpointer saves the state after every superstep, filed under a
thread id. Invoke the same graph with the same thread and it carries on.
"""

from operator import add
from typing import Annotated, TypedDict

from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import START, END, StateGraph


class State(TypedDict):
    seen: Annotated[list[str], add]


def remember(state: State) -> dict:
    return {"seen": ["visit " + str(len(state["seen"]) + 1)]}


builder = StateGraph(State)
builder.add_node("remember", remember)
builder.add_edge(START, "remember")
builder.add_edge("remember", END)

# Without this argument the graph is stateless between invocations, which
# is the default and the thing people are surprised by.
graph = builder.compile(checkpointer=MemorySaver())

guest = {"configurable": {"thread_id": "guest-1"}}
other = {"configurable": {"thread_id": "guest-2"}}

print("guest-1:", graph.invoke({"seen": []}, guest)["seen"])
print("guest-1:", graph.invoke({"seen": []}, guest)["seen"])
print("guest-1:", graph.invoke({"seen": []}, guest)["seen"])

# A different thread id is a different conversation, same graph object.
print("guest-2:", graph.invoke({"seen": []}, other)["seen"])

# The history is queryable, which is what makes debugging a graph bearable.
history = list(graph.get_state_history(guest))
print("checkpoints kept for guest-1:", len(history))
print("latest state            :", graph.get_state(guest).values["seen"])

📋 THREE VISITS, THEN A DIFFERENT GUEST

guest-1: ['visit 1']
guest-1: ['visit 1', 'visit 2']
guest-1: ['visit 1', 'visit 2', 'visit 3']
guest-2: ['visit 1']
checkpoints kept for guest-1: 9
latest state            : ['visit 1', 'visit 2', 'visit 3']
THE INPUT SAYS seen: [] EVERY TIME, AND IT STILL GROWS That is the part worth staring at. Each call passes an empty list, yet guest-1 counts up to three, because the checkpointer loads the saved state first and your input is merged into it by the reducer rather than replacing it. Change the thread id and you get a clean slate from the same compiled graph, which is how one process serves many conversations. Nine checkpoints for three invocations, because it saves after every superstep and not once per call — and get_state_history handing those back is the difference between debugging a graph and guessing at one. นั่นคือส่วนที่ควรจ้องดู ทุกการเรียกส่งลิสต์ว่างเข้าไป แต่ guest-1 กลับนับขึ้นไปถึงสาม เพราะตัวเช็กพอยต์โหลดสถานะที่บันทึกไว้ก่อน แล้วอินพุตของคุณถูกรวมเข้าไปด้วย reducer แทนที่จะไปแทนที่มัน เปลี่ยน thread id แล้วคุณจะได้กระดานเปล่าจากกราฟที่คอมไพล์แล้วอันเดียวกัน ซึ่งเป็นวิธีที่โปรเซสเดียวให้บริการหลายบทสนทนา เก้าเช็กพอยต์สำหรับการเรียกสามครั้ง เพราะมันบันทึกหลังทุก superstep ไม่ใช่ครั้งเดียวต่อการเรียก และการที่ get_state_history คืนสิ่งเหล่านั้นมา คือความต่างระหว่างการดีบักกราฟกับการเดามัน 这才是值得盯着看的地方。每次调用传进去的都是空列表,而 guest-1 却一路数到三,因为检查点器先把保存的状态加载回来,然后你的输入是被 reducer 合并进去的,而不是替换掉它。换一个 thread id,你就从同一个已编译的图拿到一块白板——这就是一个进程如何服务许多段对话的。三次调用产生九个检查点,因为它是在每个 superstep 之后保存,而不是每次调用存一次;而 get_state_history 能把这些还给你,正是「调试一张图」和「猜一张图」之间的区别。
MemorySaver IS A DICT. IT DIES WITH THE PROCESS. It is the right thing for this tutorial and the wrong thing for anything you deploy: restart the server and every conversation is gone. For real use swap it for the SQLite or Postgres checkpointer — the compile line changes and nothing else does, which is the one genuinely nice thing about how this is designed. มันเหมาะกับบทเรียนนี้ และไม่เหมาะกับอะไรก็ตามที่คุณจะดีพลอย รีสตาร์ตเซิร์ฟเวอร์แล้วบทสนทนาทั้งหมดหายหมด สำหรับใช้งานจริงให้เปลี่ยนไปใช้เช็กพอยต์แบบ SQLite หรือ Postgres บรรทัด compile เปลี่ยนไปบรรทัดเดียวและไม่มีอย่างอื่นเปลี่ยน ซึ่งเป็นข้อดีจริง ๆ ข้อเดียวของการออกแบบแบบนี้ 它对这份教程是对的选择,对任何你要上线的东西都是错的:服务器一重启,所有对话就没了。真要用就换成 SQLite 或 Postgres 的检查点器——compile 那一行变了,别的都不用变,这是这套设计里真正让人舒服的一点。

💸 Tutorial 5 — What it actually costs · บทที่ 5 — ราคาจริงของมัน · 教程 5 —— 它到底要花多少

🇬🇧 English

Same question, same model, same machine. The only variable is the graph, so the difference is the graph's bill.

🇹🇭 ไทย

คำถามเดียวกัน โมเดลเดียวกัน เครื่องเดียวกัน ตัวแปรเดียวคือกราฟ ดังนั้นส่วนต่างคือใบเสร็จของกราฟ

🇨🇳 中文

同一个问题、同一个模型、同一台机器。唯一的变量是那张图,所以差额就是图的账单。

7 Four calls against one, measured · สี่ครั้งเทียบกับหนึ่งครั้ง วัดจริง · 四次调用对一次,实测

YOU GET A number to put against the architecture, and a result that does not go the way the framing usually goes. ตัวเลขไว้เทียบกับสถาปัตยกรรม และผลลัพธ์ที่ไม่ได้ออกมาอย่างที่การตั้งคำถามแบบนี้มักจะออก 一个可以拿来衡量这套架构的数字,以及一个不按常理走向的结果。

📄 measure.py

"""What the supervisor actually costs, against one plain call.

Same question, same model, same machine. The only difference is the
graph, so whatever the numbers say is the graph's bill.
"""

import os
import time
from operator import add
from typing import Annotated, TypedDict

from langchain_ollama import ChatOllama
from langgraph.graph import START, END, StateGraph

OLLAMA = os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434")
MAX_TURNS = 4

REQUEST = ("We land Friday night and want something local to eat. "
           "How do we get in from the airport, and what should we order?")

SPECIALISTS = {
    "transport": "You handle getting around Chiang Mai: airport, songthaews, grab.",
    "food": "You handle eating in Chiang Mai: dishes, markets, what to order.",
}


class Meter:
    """Wraps the model so every call through the graph is counted."""

    def __init__(self):
        self.model = ChatOllama(model="qwen2.5:14b", base_url=OLLAMA, temperature=0)
        self.calls = 0
        self.sent = 0
        self.produced = 0

    def invoke(self, prompt):
        reply = self.model.invoke(prompt)
        meta = reply.response_metadata
        self.calls += 1
        self.sent += meta.get("prompt_eval_count", 0)
        self.produced += meta.get("eval_count", 0)
        return reply


class State(TypedDict):
    request: str
    answers: Annotated[list[str], add]
    done: Annotated[list[str], add]
    next: str
    turns: int


def build(meter):
    def supervisor(state):
        left = [n for n in SPECIALISTS if n not in state["done"]]
        if not left or state["turns"] >= MAX_TURNS:
            return {"next": "finish", "turns": state["turns"] + 1}
        prompt = ("A guest wrote: " + state["request"]
                  + "\n\nWhich specialist should answer next? Choose one of: "
                  + ", ".join(left) + ". Reply with the name alone.")
        choice = meter.invoke(prompt).content.strip().lower()
        picked = left[0]
        for name in left:
            if name in choice:
                picked = name
                break
        return {"next": picked, "turns": state["turns"] + 1}

    def make_specialist(name):
        def specialist(state):
            prompt = (SPECIALISTS[name] + "\nAnswer only the part of this you "
                      "are responsible for, in one sentence.\n\n" + state["request"])
            return {"answers": [meter.invoke(prompt).content.strip()],
                    "done": [name]}
        return specialist

    builder = StateGraph(State)
    builder.add_node("supervisor", supervisor)
    for name in SPECIALISTS:
        builder.add_node(name, make_specialist(name))
    builder.add_edge(START, "supervisor")
    builder.add_conditional_edges(
        "supervisor", lambda s: s["next"],
        {"transport": "transport", "food": "food", "finish": END})
    for name in SPECIALISTS:
        builder.add_edge(name, "supervisor")
    return builder.compile()


def report(label, meter, seconds, text):
    print(f"{label:<12} calls {meter.calls}  sent {meter.sent:>4} tok  "
          f"produced {meter.produced:>4} tok  {seconds:5.1f}s")
    print(f"             {text[:96]}")


single = Meter()
started = time.time()
plain = single.invoke("Answer in two sentences.\n\n" + REQUEST).content.strip()
report("one call", single, time.time() - started, plain.replace("\n", " "))

supervised = Meter()
started = time.time()
result = build(supervised).invoke({"request": REQUEST, "answers": [], "done": [],
                             "next": "", "turns": 0})
report("supervisor", supervised, time.time() - started, " | ".join(result["answers"]).replace("\n", " "))

print(f"\nthe graph cost {supervised.calls / single.calls:.0f}x the calls and "
      f"{(supervised.sent + supervised.produced) / (single.sent + single.produced):.1f}x the tokens")

📋 MEASURED, AND IDENTICAL ACROSS THREE RUNS

one call     calls 1  sent   60 tok  produced   74 tok    6.0s
             To get from the airport to your destination, you can use public transportation like the metro or
supervisor   calls 4  sent  328 tok  produced   80 tok    4.0s
             From the airport, take a songthaew or Grab car into Chiang Mai and look for a local restaurant s

the graph cost 4x the calls and 3.0x the tokens
One callเรียกครั้งเดียว一次调用Supervisorผู้ควบคุม主管版
Model callsจำนวนครั้งที่เรียกโมเดล模型调用次数14
Tokens sentโทเคนที่ส่งไป发出的 token60328 — five and a half times328 ห้าเท่าครึ่ง328 —— 五倍半
Tokens producedโทเคนที่ผลิตออกมา产出的 token7480 — barely moved80 แทบไม่ขยับ80 —— 几乎没动
Wall clockเวลาจริง墙上时间6.1 s3.9 s — faster, despite four calls3.9 วิ เร็วกว่า ทั้งที่เรียกสี่ครั้ง3.9 秒 —— 更快,尽管调了四次
Answer qualityคุณภาพคำตอบ答案质量invented a metroกุรถไฟใต้ดินขึ้นมา编出了一条地铁songthaew, Grab, khao soiสองแถว Grab ข้าวซอย双条车、Grab、khao soi
THE SINGLE CALL WAS CHEAPER, SLOWER, AND WRONG Chiang Mai has no metro. The one-call version recommended one, because a general question got a general answer. The supervisor version named songthaews, Grab and khao soi, because each specialist was asked a narrow question it could answer well. So the three times token cost bought something real — but look at where it went: sent tokens went 60 to 328 while produced tokens went 74 to 80. You are not paying for more thinking. You are paying to re-send the same request to everybody, which is why multi-agent bills grow with the number of agents and not with the difficulty of the problem. เชียงใหม่ไม่มีรถไฟใต้ดิน เวอร์ชันเรียกครั้งเดียวแนะนำรถไฟใต้ดิน เพราะคำถามกว้าง ๆ ได้คำตอบกว้าง ๆ เวอร์ชันผู้ควบคุมระบุสองแถว Grab และข้าวซอย เพราะผู้เชี่ยวชาญแต่ละคนถูกถามคำถามแคบ ๆ ที่ตอบได้ดี ดังนั้นค่าโทเคนสามเท่าซื้อของจริงมาได้ แต่ดูว่ามันไปอยู่ตรงไหน โทเคนที่ส่งไปขยับจาก 60 เป็น 328 ขณะที่โทเคนที่ผลิตขยับจาก 74 เป็น 80 คุณไม่ได้จ่ายเพื่อให้มันคิดมากขึ้น คุณจ่ายเพื่อส่งคำขอเดิมซ้ำให้ทุกคน ซึ่งเป็นเหตุผลที่บิลของระบบหลายเอเจนต์โตตามจำนวนเอเจนต์ ไม่ใช่ตามความยากของปัญหา 清迈没有地铁。一次调用的版本推荐了地铁,因为一个笼统的问题得到一个笼统的答案。主管版说出了双条车、Grab 和 khao soi,因为每个专家被问的都是一个它能答好的窄问题。所以三倍的 token 成本确实买到了东西——但看看它花在哪儿了:发出的 token 从 60 涨到 328,而产出的 token 从 74 只到 80。你付的不是「想得更多」的钱。你付的是把同一条请求重发给每个人的钱,这就是为什么多智能体的账单随智能体数量增长,而不是随问题难度增长。
AND WHY WAS IT FASTER? Because generation dominates on a local model and the four calls produced 80 tokens between them against 74 in one go, while the extra input tokens are processed in parallel and cost almost nothing. On a hosted API billed per token this ordering flips: you would pay 3x and wait longer, because each call is a separate round trip over the network. Do not carry this number to a different setup — run measure.py against your own. เพราะการสร้างข้อความคือส่วนที่กินเวลาหลักบนโมเดลในเครื่อง และการเรียกสี่ครั้งผลิตรวมกัน 80 โทเคน เทียบกับ 74 โทเคนในครั้งเดียว ขณะที่โทเคนอินพุตส่วนเกินถูกประมวลผลแบบขนานและแทบไม่มีต้นทุน บน API ที่โฮสต์ไว้และคิดเงินต่อโทเคน ลำดับนี้จะกลับด้าน คุณจะจ่ายสามเท่าและรอนานกว่า เพราะแต่ละครั้งคือการวิ่งไปกลับผ่านเครือข่ายแยกกัน อย่าเอาตัวเลขนี้ไปใช้กับสภาพแวดล้อมอื่น ให้รัน measure.py กับของคุณเอง 因为在本地模型上生成才是大头,而四次调用一共产出 80 个 token,对比一次产出 74 个;与此同时多出来的输入 token 是并行处理的,几乎不花时间。在按 token 计费的托管 API 上,这个顺序会反过来:你会付三倍的钱,还等得更久,因为每次调用都是一次独立的网络往返。别把这个数字搬到别的环境——拿 measure.py 去跑你自己的。

🪤 Where this falls over · มันพังตรงไหน · 它在哪里翻车

Problemปัญหา问题What actually happensเกิดอะไรขึ้นจริง实际会怎样
The missing reducerreducer ที่หายไป缺失的 reducerStep 2, and it never stops being the answer. Silent data loss with no warning, and in a supervisor graph it also causes an infinite loop, because the list tracking who has finished keeps getting wiped.ขั้นที่ 2 และมันเป็นคำตอบเสมอไม่มีวันหมด ข้อมูลหายเงียบ ๆ โดยไม่มีคำเตือน และในกราฟผู้ควบคุมมันยังทำให้เกิดลูปไม่รู้จบด้วย เพราะลิสต์ที่ติดตามว่าใครทำเสร็จแล้วถูกล้างซ้ำ ๆ第 2 步,而且它永远都还是那个答案。无声的数据丢失,没有任何警告;在主管图里它还会造成无限循环,因为记录谁已完成的那个列表一直被清掉。
Trusting the model to stopเชื่อว่าโมเดลจะหยุดเอง指望模型自己停Step 4 measured it: told a countable rule, the model approved a draft that broke it. Any loop whose only exit is the model's agreement needs a counter you wrote.ขั้นที่ 4 วัดไว้แล้ว เมื่อได้รับกฎที่นับได้ โมเดลอนุมัติร่างที่ผิดกฎนั้น ลูปไหนก็ตามที่มีทางออกทางเดียวคือโมเดลเห็นด้วย ต้องมีตัวนับที่คุณเขียนเอง第 4 步已经量过了:给了一条可数的规则,模型批准了一份违反它的草稿。任何唯一出口是「模型同意」的循环,都需要一个你自己写的计数器。
Specialists overlappingผู้เชี่ยวชาญทำงานทับกัน专家互相越界Step 5. Told to answer only their own part, both answered the transport question. You pay twice and eventually get two answers that disagree.ขั้นที่ 5 ทั้งที่ถูกสั่งให้ตอบเฉพาะส่วนของตัวเอง ทั้งคู่กลับตอบคำถามเรื่องการเดินทาง คุณจ่ายสองครั้งและสุดท้ายจะได้คำตอบสองอันที่ขัดกัน第 5 步。明明被要求只答自己那部分,两个都回答了交通问题。你付两次钱,最终还会拿到两个互相矛盾的答案。
Cost grows with agentsค่าใช้จ่ายโตตามจำนวนเอเจนต์成本随智能体数量增长Step 7. Sent tokens rose five and a half times while produced tokens stayed flat, because the request is re-sent to everyone. Adding a fourth specialist adds its share again.ขั้นที่ 7 โทเคนที่ส่งไปเพิ่มขึ้นห้าเท่าครึ่งขณะที่โทเคนที่ผลิตแทบไม่ขยับ เพราะคำขอถูกส่งซ้ำให้ทุกคน การเพิ่มผู้เชี่ยวชาญคนที่สี่ก็เพิ่มส่วนของมันเข้าไปอีก第 7 步。发出的 token 涨了五倍半,而产出的 token 基本没动,因为请求被重发给了每个人。再加第四个专家,就再加一份。
MemorySaver in productionMemorySaver บนโปรดักชัน拿 MemorySaver 上生产It is a dictionary. Every tutorial uses it, and it loses every conversation on restart. Swap the checkpointer before you deploy, not after.มันคือดิกชันนารี ทุกบทเรียนใช้มัน และมันทำบทสนทนาทั้งหมดหายตอนรีสตาร์ต ให้เปลี่ยนตัวเช็กพอยต์ก่อนดีพลอย ไม่ใช่หลังจากนั้น它就是个字典。每份教程都在用它,而它一重启就丢掉所有对话。在上线之前换掉检查点器,不是之后。
Is a graph even needed?จำเป็นต้องใช้กราฟจริงไหม真的需要一张图吗If your flow is A then B with an if in the middle, a function does it with less ceremony. The graph earns its place when you need to resume, branch visibly, or let more than one writer touch the same state.ถ้าโฟลว์ของคุณคือ A แล้ว B โดยมี if ตรงกลาง ฟังก์ชันธรรมดาก็ทำได้โดยไม่ต้องมีพิธีรีตอง กราฟจะคุ้มก็ต่อเมื่อคุณต้องทำงานต่อจากจุดเดิม แตกกิ่งแบบมองเห็นได้ หรือให้มีผู้เขียนมากกว่าหนึ่งรายแตะสถานะเดียวกัน如果你的流程就是 A 然后 B、中间一个 if,用一个函数就行,省掉这套仪式。图值回票价的时候,是你需要断点续跑、需要看得见的分支,或者需要不止一个写入者去碰同一份状态。

🇬🇧 English

Next: smolagents, which gives the model a Python interpreter instead of a graph, CrewAI and AutoGen, or Jev — where the routing decision comes back as a probability instead of a word you have to narrow.

🇹🇭 ไทย

ต่อไป: smolagents ซึ่งให้ตัวแปล Python กับโมเดลแทนที่จะให้กราฟ CrewAI และ AutoGen หรือ Jev ที่การตัดสินใจเรื่องเส้นทางกลับมาเป็นความน่าจะเป็น แทนที่จะเป็นคำที่คุณต้องมาทำให้แคบลง

🇨🇳 中文

接下来:smolagents,它给模型的是一个 Python 解释器而不是一张图;CrewAI 和 AutoGen;或者 Jev——那里路由决策回来的是一个概率,而不是一个还要你去收窄的词。

State, a node, an edge. Everything else on this page is those three things wearing a hat. · สถานะ โหนด เส้นเชื่อม ทุกอย่างที่เหลือในหน้านี้คือสามสิ่งนี้ที่ใส่หมวกมา · 状态、节点、边。这一页上其余的一切,都是这三样东西戴了顶帽子。

All seven files were written and run on 26 September 2026 against langgraph 1.2.12 and langchain-ollama 1.1.0, and every output block on this page is copied from those runs. The model was qwen2.5:14b served by Ollama on a DGX Spark over the LAN, at temperature 0; the measurement in Step 7 was repeated three times and came back identical, including the timings, which is what temperature 0 and an unchanged prompt will do. Three results went against the intent of the code rather than with it, and all three are kept: in Step 4 the model approved a six-word tagline containing a colon after being told to approve only five words with no colon, and the writer never satisfied the rule-based editor either, so MAX_ROUNDS ended that loop rather than success; in Step 5 the food specialist answered the transport question as well as its own; in Step 7 the single call invented a metro for a city that has none. The Step 7 timing favours the graph only because generation dominates on a local model — on a per-token hosted API the ordering would reverse, and the token counts, not the seconds, are the number to carry. No paid API was called at any point.