对应代码:
app/agent/graph.py、state.py
一、为什么不是"一次 LLM + Function Calling"
单轮 Function Calling 的三个硬伤:
- 只能调用一次工具:拿到结果后不能再触发新的查询
- 工具报错就整轮失败:没有自愈机会
- 复杂问题做不完:比如"先查制度阈值 → 再查实际数据 → 对比 → 给建议"
所以引入 Plan → Execute → Reflect → Answer。
二、四个节点在干什么
| 节点 | 输入 | 输出 | 关键点 |
|---|---|---|---|
| Plan | 问题 + 工具清单 + 历史摘要 | JSON 步骤(1~3 步) | 温度 0、强制 JSON、容错解析 |
| Execute | 步骤列表 | observations | 逐个执行;报错不抛异常,写进 observation |
| Reflect | observations | enough / next_steps |
判断信息是否足够;不够最多补 2 步 |
| Answer | context + observations | 流式答案 | 引用编号 + 口径说明 |
Plan 节点
python
prompt = PLANNER_TMPL.format(
system_role=SYSTEM_ROLE,
tools=self.registry.describe_for_prompt(), # 工具清单自动生成
summary=state["summary"],
question=state["query"])
raw = llm.chat(messages, json_mode=True)
data = parse_json_safe(raw)
plan = [s for s in data["steps"] if self.registry.get(s["tool"])][:3]
两个细节:
- 工具清单自动生成:新增工具不用改 Prompt
- 过滤未知工具:模型偶尔会"幻觉"出一个不存在的工具名
Execute 节点
python
for step in plan:
result = self.registry.execute(name, args, trace_id)
if result.error == "HITL_REQUIRED":
return {"status": "waiting_human", "pending_hitl": {...}} # 中断
observations.append({"tool": name, "ok": result.ok, "output": ...})
# 知识库结果 → 拼 context + 记录引用
关键决策:工具报错不抛异常,而是变成一条 observation 交给模型。 这样模型能看到"SQL 字段不存在"并自己换一种写法。
Reflect 节点
python
{"enough": true/false, "missing": "...", "next_steps": [...]}
"让模型判断信息够不够"这件事,看起来简单,实际是准确率提升最明显的一环。
三、用 LangGraph 建图
python
g = StateGraph(AgentState)
g.add_node("plan", self._node_plan)
g.add_node("execute", self._node_execute)
g.add_node("reflect", self._node_reflect)
g.add_node("answer", self._node_answer)
g.set_entry_point("plan")
g.add_conditional_edges("plan", self._route_after_plan,
{"execute": "execute", "answer": "answer"})
g.add_edge("execute", "reflect")
g.add_conditional_edges("reflect", self._route_after_reflect,
{"execute": "execute", "answer": "answer"})
g.add_edge("answer", END)
路由函数:
python
@staticmethod
def _route_after_plan(state):
return "execute" if state.get("plan") else "answer"
def _route_after_reflect(self, state):
if state.get("status") == "answer_ready": return "answer"
if state.get("iteration", 0) >= settings.max_iterations: return "answer"
return "execute" if state.get("plan") else "answer"
为什么 State 用 TypedDict 而不是 Pydantic
LangGraph 对 dict 做增量合并,序列化成本低,整份 state 可以直接落盘做回放。
python
class AgentState(TypedDict, total=False):
query, trace_id, thread_id
summary, history
context, citations, docs
plan, observations, iteration
pending_hitl, hitl_approved
final_answer, error, status
四、防死循环:三道闸
这是血泪经验。 没有上限的 Agent 在"工具一直返回空"时会无限循环, 一晚上烧掉几百块 token。
python
MAX_ITERATIONS = 6 # Reflect 判断迭代次数,超限强制 Answer
Reflect 最多补 2 步
Plan 最多 3 步
五、降级内核:不依赖框架也能跑
python
def _build_langgraph(self):
try:
from langgraph.graph import END, StateGraph
except Exception:
return None # 装不上就返回 None
...
def _execute_graph(self, state):
if self.graph is not None:
try:
return self.graph.invoke(dict(state))
except Exception as e:
self._emit({"type": "warn", "message": f"langgraph 异常,降级:{e}"})
# 等价的手写状态机
cur.update(self._node_plan(cur))
while self._route_after_plan(cur) == "execute":
cur.update(self._node_execute(cur))
if cur.get("status") == "waiting_human": return cur
cur.update(self._node_reflect(cur))
if self._route_after_reflect(cur) != "execute": break
cur.update(self._node_answer(cur))
为什么值得写这 20 行:
- 环境装不上 langgraph 也能跑(CI / 内网)
- 面试时最能说明你不是在调库------你能说清"图"和"手写循环"的等价关系
- 框架出 bug 时自动降级,生产多一层保险
六、Human-in-the-loop:中断而不是拒绝
高危动作的处理流程:
ini
Execute 遇到 risk=high 的工具
│
├─ 未确认 → 记入 self._pending[thread_id],状态改 waiting_human,路由到 Answer
│ → 前端弹出确认卡片
│
└─ 用户点确认 → 带 approved=true 再次请求
→ _resume_after_approval() 取出 pending 并执行
→ 继续走到 Answer
python
def _resume_after_approval(self, state):
pending = self._pending.pop(state["thread_id"])
result = self.registry.execute(pending["tool"], pending["args"],
state["trace_id"], auto_approve_high_risk=True)
state["observations"].append({...})
state["status"] = "answer_ready"
七、流式输出:怎么做到"边想边说"
- 规划/工具/反思这些过程事件 通过
sink回调实时推给 SSE - 最终答案用
llm.stream()逐 token 推:
python
for delta in get_llm().stream([...], trace_id=trace_id):
chunks.append(delta)
self._emit({"type": "token", "delta": delta})
前端就能同时看到"执行链路时间线"和"打字机答案"。
八、一次真实执行(DeepSeek 实测)
ini
用户:本月各项目成本排名前 3 是多少?
[plan] sql_query(question="本月各项目成本排名前3")
[tool] sql_query ✅ → 返回空(月份格式问题)
[reflect] enough=false, missing="需要确认月份字段格式"
[plan] sql_query(question="最近有数据的月份各项目成本合计排名前3")
[tool] sql_query ✅ → 3 行数据
[reflect] enough=true
[answer] 结论:2026年9月成本 Top3 为 海外支付网关(80.26)、数据资产平台(77.94)...
说明:直接查询"本月"返回空行 [2][3],可能因月份字段格式为 '2026-09-01'...
注意最后那段------模型自己解释了数据异常的原因。 这就是 Reflect 节点的价值:它不只是"多调一次工具",而是让整个回答更可信。