两份代码解决同一个问题:让模型「思考 → 调工具 → 看结果 → 再思考」。
一份用
prompt + 正则 + while手写(test.py),一份用StateGraph + ToolNode手写(langgraph_demo.py)。本文拆解两种实现,并对比它们在决策、执行、状态、终止和扩展性上的差异。
一、先看两张图
LangChain 版:文本协议驱动
#mermaid-svg-D9N3HyfvvGnioIZa{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-D9N3HyfvvGnioIZa .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-D9N3HyfvvGnioIZa .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-D9N3HyfvvGnioIZa .error-icon{fill:#552222;}#mermaid-svg-D9N3HyfvvGnioIZa .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-D9N3HyfvvGnioIZa .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-D9N3HyfvvGnioIZa .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-D9N3HyfvvGnioIZa .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-D9N3HyfvvGnioIZa .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-D9N3HyfvvGnioIZa .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-D9N3HyfvvGnioIZa .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-D9N3HyfvvGnioIZa .marker{fill:#333333;stroke:#333333;}#mermaid-svg-D9N3HyfvvGnioIZa .marker.cross{stroke:#333333;}#mermaid-svg-D9N3HyfvvGnioIZa svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-D9N3HyfvvGnioIZa p{margin:0;}#mermaid-svg-D9N3HyfvvGnioIZa .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-D9N3HyfvvGnioIZa .cluster-label text{fill:#333;}#mermaid-svg-D9N3HyfvvGnioIZa .cluster-label span{color:#333;}#mermaid-svg-D9N3HyfvvGnioIZa .cluster-label span p{background-color:transparent;}#mermaid-svg-D9N3HyfvvGnioIZa .label text,#mermaid-svg-D9N3HyfvvGnioIZa span{fill:#333;color:#333;}#mermaid-svg-D9N3HyfvvGnioIZa .node rect,#mermaid-svg-D9N3HyfvvGnioIZa .node circle,#mermaid-svg-D9N3HyfvvGnioIZa .node ellipse,#mermaid-svg-D9N3HyfvvGnioIZa .node polygon,#mermaid-svg-D9N3HyfvvGnioIZa .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-D9N3HyfvvGnioIZa .rough-node .label text,#mermaid-svg-D9N3HyfvvGnioIZa .node .label text,#mermaid-svg-D9N3HyfvvGnioIZa .image-shape .label,#mermaid-svg-D9N3HyfvvGnioIZa .icon-shape .label{text-anchor:middle;}#mermaid-svg-D9N3HyfvvGnioIZa .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-D9N3HyfvvGnioIZa .rough-node .label,#mermaid-svg-D9N3HyfvvGnioIZa .node .label,#mermaid-svg-D9N3HyfvvGnioIZa .image-shape .label,#mermaid-svg-D9N3HyfvvGnioIZa .icon-shape .label{text-align:center;}#mermaid-svg-D9N3HyfvvGnioIZa .node.clickable{cursor:pointer;}#mermaid-svg-D9N3HyfvvGnioIZa .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-D9N3HyfvvGnioIZa .arrowheadPath{fill:#333333;}#mermaid-svg-D9N3HyfvvGnioIZa .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-D9N3HyfvvGnioIZa .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-D9N3HyfvvGnioIZa .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-D9N3HyfvvGnioIZa .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-D9N3HyfvvGnioIZa .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-D9N3HyfvvGnioIZa .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-D9N3HyfvvGnioIZa .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-D9N3HyfvvGnioIZa .cluster text{fill:#333;}#mermaid-svg-D9N3HyfvvGnioIZa .cluster span{color:#333;}#mermaid-svg-D9N3HyfvvGnioIZa div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-D9N3HyfvvGnioIZa .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-D9N3HyfvvGnioIZa rect.text{fill:none;stroke-width:0;}#mermaid-svg-D9N3HyfvvGnioIZa .icon-shape,#mermaid-svg-D9N3HyfvvGnioIZa .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-D9N3HyfvvGnioIZa .icon-shape p,#mermaid-svg-D9N3HyfvvGnioIZa .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-D9N3HyfvvGnioIZa .icon-shape .label rect,#mermaid-svg-D9N3HyfvvGnioIZa .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-D9N3HyfvvGnioIZa .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-D9N3HyfvvGnioIZa .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-D9N3HyfvvGnioIZa :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 有
无
用户问题
LLM 生成 Thought / Action
正则匹配 Action?
tool_mapname(input)
拼成 'Observation: ...'
返回消息列表
LangGraph 版:状态图驱动
#mermaid-svg-QKEUMUnzVaxBgoHn{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QKEUMUnzVaxBgoHn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QKEUMUnzVaxBgoHn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QKEUMUnzVaxBgoHn .error-icon{fill:#552222;}#mermaid-svg-QKEUMUnzVaxBgoHn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QKEUMUnzVaxBgoHn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QKEUMUnzVaxBgoHn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QKEUMUnzVaxBgoHn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QKEUMUnzVaxBgoHn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QKEUMUnzVaxBgoHn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QKEUMUnzVaxBgoHn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QKEUMUnzVaxBgoHn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QKEUMUnzVaxBgoHn .marker.cross{stroke:#333333;}#mermaid-svg-QKEUMUnzVaxBgoHn svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QKEUMUnzVaxBgoHn p{margin:0;}#mermaid-svg-QKEUMUnzVaxBgoHn .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-QKEUMUnzVaxBgoHn .cluster-label text{fill:#333;}#mermaid-svg-QKEUMUnzVaxBgoHn .cluster-label span{color:#333;}#mermaid-svg-QKEUMUnzVaxBgoHn .cluster-label span p{background-color:transparent;}#mermaid-svg-QKEUMUnzVaxBgoHn .label text,#mermaid-svg-QKEUMUnzVaxBgoHn span{fill:#333;color:#333;}#mermaid-svg-QKEUMUnzVaxBgoHn .node rect,#mermaid-svg-QKEUMUnzVaxBgoHn .node circle,#mermaid-svg-QKEUMUnzVaxBgoHn .node ellipse,#mermaid-svg-QKEUMUnzVaxBgoHn .node polygon,#mermaid-svg-QKEUMUnzVaxBgoHn .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-QKEUMUnzVaxBgoHn .rough-node .label text,#mermaid-svg-QKEUMUnzVaxBgoHn .node .label text,#mermaid-svg-QKEUMUnzVaxBgoHn .image-shape .label,#mermaid-svg-QKEUMUnzVaxBgoHn .icon-shape .label{text-anchor:middle;}#mermaid-svg-QKEUMUnzVaxBgoHn .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-QKEUMUnzVaxBgoHn .rough-node .label,#mermaid-svg-QKEUMUnzVaxBgoHn .node .label,#mermaid-svg-QKEUMUnzVaxBgoHn .image-shape .label,#mermaid-svg-QKEUMUnzVaxBgoHn .icon-shape .label{text-align:center;}#mermaid-svg-QKEUMUnzVaxBgoHn .node.clickable{cursor:pointer;}#mermaid-svg-QKEUMUnzVaxBgoHn .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-QKEUMUnzVaxBgoHn .arrowheadPath{fill:#333333;}#mermaid-svg-QKEUMUnzVaxBgoHn .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-QKEUMUnzVaxBgoHn .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-QKEUMUnzVaxBgoHn .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QKEUMUnzVaxBgoHn .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-QKEUMUnzVaxBgoHn .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QKEUMUnzVaxBgoHn .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-QKEUMUnzVaxBgoHn .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-QKEUMUnzVaxBgoHn .cluster text{fill:#333;}#mermaid-svg-QKEUMUnzVaxBgoHn .cluster span{color:#333;}#mermaid-svg-QKEUMUnzVaxBgoHn div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-QKEUMUnzVaxBgoHn .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-QKEUMUnzVaxBgoHn rect.text{fill:none;stroke-width:0;}#mermaid-svg-QKEUMUnzVaxBgoHn .icon-shape,#mermaid-svg-QKEUMUnzVaxBgoHn .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QKEUMUnzVaxBgoHn .icon-shape p,#mermaid-svg-QKEUMUnzVaxBgoHn .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-QKEUMUnzVaxBgoHn .icon-shape .label rect,#mermaid-svg-QKEUMUnzVaxBgoHn .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QKEUMUnzVaxBgoHn .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-QKEUMUnzVaxBgoHn .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-QKEUMUnzVaxBgoHn :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 最后一条消息有 tool_calls
且 steps 未超限
无 tool_calls 或超步
START
thought_node
bind_tools + invoke
should_continue
action_node
ToolNode 执行
final_answer_node
END
同样的循环,一个藏在 while 和字符串里,一个显式画在图上。
二、LangChain 版拆解:把协议写进提示词
2.1 决策靠提示词
系统提示词定义了整套「语言协议」:
text
Thought: ...
Action: fetch_real_time_info: mass of earth
PAUSE
模型必须按这个格式输出,代码才知道下一步做什么。提示词里还塞了两段 few-shot 示例,教模型「什么时候查、什么时候算、什么时候给 Answer」。
2.2 解析靠正则
python
action_re = re.compile(r'^Action: (\w+): (.*)$')
actions = [action_re.match(a) for a in result.split('\n') if action_re.match(a)]
if actions:
action, action_input = actions[0].groups()
observation = tool_map[action](action_input)
next_message = f"Observation: {observation}"
关键点:
- 一行只能有一个 Action,且格式必须严格是
Action: 工具名: 参数; - 只取
actions[0],天然不支持一轮多工具; - 工具参数是字符串 ,
calculate里直接eval,没有类型校验。
2.3 循环靠 while
python
while i <= max_time:
i += 1
result = chatBot(next_message)
actions = [...]
if actions:
...
next_message = f"Observation: {observation}"
else:
return chatBot.message
终止条件只有一个:模型不再输出 Action。max_time 用尽时函数没有返回值,会隐式返回 None------这是第一版留下的坑。
2.4 状态靠一个列表
python
class ChatBot:
def __init__(self, promot):
self.message = [{"role": "system", "content": promot}]
def __call__(self, message):
self.message.append({"role": "user", "content": message})
result = self.excutor()
self.message.append({"role": "assistant", "content": result})
return result
对话历史就是 self.message,没有类型、没有 reducer、没有持久化。Observation 是作为下一条 user 消息塞回去的,模型看到的其实是「用户说了 Observation」。
三、LangGraph 版拆解:把循环画成图
3.1 状态显式化
python
class AgentState(TypedDict):
messages: Annotated[List, add_messages]
answer: str
is_last_step: int
steps: int
四个字段各自明确:
messages:完整消息历史,add_messages自动追加;answer:最终答案;steps/is_last_step:轮次计数与上限。
TypedDict 不能给默认值,所以调用时必须显式传:
python
graph.invoke({
"messages": [HumanMessage(content=query)],
"is_last_step": 5,
"steps": 0,
})
3.2 三个节点
thought_node:模型决策
python
def thought_node(state):
model_with_tool = model.bind_tools(tools)
response = model_with_tool.invoke(
[SystemMessage(content=prompt)] + state["messages"]
)
return {"messages": [response], "steps": state["steps"] + 1}
和 LangChain 版最大的区别:这里不要求模型输出特定格式的文本 ,而是把工具 schema 交给模型,让模型直接产出 AIMessage.tool_calls。
action_node:执行工具
python
def action_node(state):
tool_node = ToolNode(tools)
response = tool_node.invoke({"messages": [state["messages"][-1]]})
return {"messages": response["messages"]}
ToolNode 读取最后一条 AIMessage 的 tool_calls,逐个执行,返回带 tool_call_id 的 ToolMessage。
final_answer_node:收尾
python
def final_answer_node(state):
if state["steps"] > state["is_last_step"]:
return {"answer": "推理次数超过最大次数,回答失败"}
answer = state["messages"][-1].content
return {"answer": answer}
3.3 两条边构成循环
python
def should_continue(state):
if state["steps"] > state["is_last_step"]:
return False
if state["messages"][-1].tool_calls:
return True
return False
state_graph.add_conditional_edges(
"thought_node",
should_continue,
{True: "action_node", False: "final_answer_node"},
)
state_graph.add_edge("action_node", "thought_node")
state_graph.add_edge("final_answer_node", END)
这里有三个与 LangChain 版本质不同的点:
- 判断依据是结构化的
tool_calls,不是文本; - 循环是图的一部分 ,
action → thought是显式回边; - 终止有两个条件:没有工具调用,或轮次超限,且超限时仍会走到一个明确的结束节点。
四、同一个问题,两条执行轨迹
查询:
text
帮我查一下北京今天天气怎么样,并保存一下
LangGraph 版的一条典型轨迹:
| 轮次 | 节点 | 模型/工具动作 | 状态变化 |
|---|---|---|---|
| 1 | thought_node |
调 get_weather(city="北京") |
steps=1,新增 AIMessage(tool_calls) |
| 1 | action_node |
执行天气 API | 新增 ToolMessage(天气 JSON) |
| 2 | thought_node |
调 save_weather(weather={...}) |
steps=2,新增 AIMessage(tool_calls) |
| 2 | action_node |
执行保存 | 新增 ToolMessage("已保存") |
| 3 | thought_node |
不再调工具,直接回答 | steps=3,新增 AIMessage(content) |
| 3 | final_answer_node |
提取最后一条消息正文 | answer=... |
注意第 2 轮的 save_weather 参数来自第 1 轮的工具结果------这种「后一个工具依赖前一个工具输出」的场景,必须靠循环才能完成,一轮工具调用做不到。
LangChain 版的轨迹形态相同,但每一轮都是一段文本:
text
result: Thought: 我需要查北京天气
Action: fetch_real_time_info: 北京天气
...
observation: ...
result: Thought: 天气结果已经拿到,需要保存
Action: ...
一个是消息对象在状态里流动,一个是字符串在提示词里滚动。
五、逐项对比
| 维度 | LangChain 手写版 | LangGraph 手写版 |
|---|---|---|
| 决策表达 | 自然语言协议(Thought/Action) | 原生 tool_calls |
| 动作解析 | 正则 ^Action: (\w+): (.*)$ |
无需解析 |
| 工具执行 | tool_map[name](input) 手动分派 |
ToolNode 统一执行 |
| 参数类型 | 字符串,eval / 直接传参 |
JSON Schema + Pydantic 校验 |
| 每轮动作数 | 只取 actions[0],一次一个 |
一条消息可含多个 tool_calls,一起执行 |
| 结果回填 | "Observation: ..." 拼进用户消息 |
ToolMessage + tool_call_id |
| 循环控制 | while i <= max_time |
条件边 + 回边 |
| 终止条件 | 模型不再输出 Action | 无 tool_calls 或 steps 超限 |
| 超限行为 | 隐式返回 None |
显式返回「回答失败」 |
| 状态 | 一个消息列表 | TypedDict + reducer |
| 可测试性 | 依赖模型输出格式,难单测 | should_continue 是纯函数,可直接断言 |
| 可观测性 | print + 文本日志 |
节点级 trace、stream 增量 |
| 扩展新分支 | 改提示词、加 tool_map、祈祷模型照做 |
加节点与边,编译期校验 |
| 提示词负担 | 需要长 few-shot 示例 | 一行流程说明即可 |
一句话总结:
LangChain 版把「协议」写在提示词里,LangGraph 版把「协议」写进类型和边里。
六、两种实现各自的坑
6.1 LangChain 版
- 正则太脆 :模型多一个空格、少一个冒号、写成
Action: Beijing.,都会解析失败; - 提示词在教坏模型 :示例里有
Action: Beijing.这种非法动作,模型会照抄; - 只取第一个 Action:天然不支持并行工具;
max_time用尽返回None:调用方拿到None而不是错误;eval执行模型生成的字符串:学习可以,生产危险;- API Key 硬编码:文件里能直接看到。
6.2 LangGraph 版
- 轮次边界会浪费一次调用 :
steps在thought_node里自增,第 6 次思考后才判断6 > 5,这一次模型调用白跑; final_answer_node依赖messages[-1].content:如果模型返回内容为空(只给工具调用或拒答),答案就是空字符串;- 工具返回值缺失 :
get_weather捕获UapiError后只print,没有return,出错时工具结果是空; save_weather参数是dict:模型要把上一轮 JSON 原样搬过来,字段结构不稳定时容易传错;- 状态无默认值 :
is_last_step、steps必须每次手动传,接前端时容易漏; - 没有 checkpointer:无法多轮会话、无法断点恢复。
这些坑的共同点:图的骨架是对的,边界条件还没打磨。
七、该用哪一种
| 场景 | 建议 |
|---|---|
| 只想理解 ReAct 的原理 | 先写 LangChain 文本版,最直观 |
| 要接真实工具、多工具、多分支 | LangGraph 版,类型和流程都更可控 |
| 需要持久化、人工确认、断点恢复 | LangGraph + checkpointer |
| 快速原型、一次性脚本 | create_agent 或文本版即可 |
| 需要向上解释「为什么这么跳」 | LangGraph,图能画出来 |
更准确地说,两种「手写」练的是不同的能力:
- LangChain 版练的是协议设计:如何让模型稳定输出可解析的动作;
- LangGraph 版练的是流程设计:如何把判断、执行、终止拆成可组合的节点与边。
八、小结
回头看,两份代码的差别不在「用哪个框架」,而在决策信息的载体:
- 文本版:模型说的话就是控制流,代码只能猜;
- 图版本:模型只负责产出结构化决策,控制流由边决定。
当工具只有一个、流程只有一条时,文本版更快;当工具变多、分支变多、需要重试和持久化时,图版本的优势会迅速放大。
而 LangGraph 版离「生产可用」其实只差几步:修正 steps 边界、给 final_answer_node 加兜底、让工具统一返回结构化结果、再挂一个 checkpointer。
循环可以手写,但边界必须显式。