做LangChain开发半年多之后,我越来越觉得传统的Chain结构根本扛不住真实业务场景。但凡涉及多轮对话的状态记录、不同意图的流程分支、或者需要在关键节点上停下来让真人确认,代码就会迅速变成一团乱麻。直到我完整把LangGraph用在一个带人工审核的客服助手上,才真正理解为什么社区里都在说“让AI真的下地干活”。这篇文章不打算重复官方文档里那些基础示例,我直接用自己重构一个协作文档Agent的完整过程,把状态管理、条件路由、人工介入这三块最核心也最容易踩坑的地方一次说透。
这个案例适合谁看?如果你已经写过几个LangChain的Chain,对LangGraph只有模糊概念,又恰好需要处理“AI可能出错但又不希望它完全自作主张”的场景,那这篇文章应该能帮你少走很多弯路。当然,如果你的项目只是单轮问答或者简单文档处理,用Chain就够了,没必要引入Graph的开销。
1. 整体设计思路:为什么状态管理决定Agent的成败
1.1 从Chain到Graph,解决的是流程失控问题
传统LangChain的Chain是线性执行的,prompt进来、模型推理、输出解析、结束。对于QA、摘要这类一次性任务够用,但真实的Agent需要循环、分支、挂起、恢复。比如客户问了一个问题,Agent可能先要查知识库,然后判断信息充足就回答,信息不足就要问用户澄清,如果涉及特殊操作还要等人工确认——这种流程图结构用Chain写起来极其别扭,硬编码if-else会越写越乱。
LangGraph的核心思路是把Agent定义成一个图。节点就是动作,边就是跳转逻辑,而整个图的“血液循环系统”则是一个全局状态对象。每一次节点执行都能读取和修改这个状态,下一个节点基于最新状态继续跑。这个模式和我以前做业务系统时用工作流引擎的感觉很像,但LangGraph为LLM场景做了专门优化,比如支持消息列表的增量更新、支持图执行到一半暂停并持久化。
我当初重构时的核心诉求有三个:一是状态必须透明,节点之间不能隐式传参,所有数据流向都看得见;二是流程必须可控,AI判断不靠谱的地方要留出人工决策口子;三是执行要可恢复,进程挂了或者人工审批时间很长,状态不能丢。
1.2 案例场景设定:一个带审批的协作文档Agent
为了让整篇文章始终有一条主线,我设定了一个完整的业务场景。假设我们正在构建一个内部知识库Agent,员工可以提问,Agent负责检索相关资料、草拟回复,但如果这个回复要被发送到外部渠道(比如客户邮件),那就必须经过运营人员的人工审核。
整个流程大致是这样:用户发起请求,Agent判断意图,如果是普通问答就直接检索生成;如果涉及对外发送,就先生成草稿、挂起、等待人工确认;人工确认后继续执行发送动作;如果人工驳回,则回到修订状态让Agent重写。这个场景天然包含了三种核心机制:多节点围绕共享状态协作(状态管理)、根据意图动态决定下一步走哪个分支(条件路由)、在关键节点暂停并等外部指令(人工介入)。
这个场景设计不是凭空拍脑袋。我之前和一个做客服系统团队聊过,他们最大的痛点就是AI直接回复用户出过错,但全人工又扛不住量。LangGraph的interrupt机制正好就补在这个位置上——让AI先干活,但在风险动作前踩一脚刹车。
2. 状态管理的底层逻辑与实战要点
2.1 State Schema定义:别贪多,够用就好
State在LangGraph里其实就是TypedDict或者Pydantic模型。我强烈建议直接用Pydantic,不是因为它能少写几行代码,而是在复杂Agent里字段多了以后,类型校验能救你很多次。比如messages这个字段,你要求它是BaseMessage列表,结果某个节点往里塞了个字符串,Pydantic当场报错,比跑到神秘的地方才发现数据类型错了舒服太多了。
我这套客服场景的状态定义大概是这样的:
from typing import Annotated, TypedDict from langgraph.graph.message import add_messages from pydantic import BaseModel, Field class AgentState(BaseModel): messages: Annotated[list, add_messages] = [] intent: str = "general" need_review: bool = False draft_reply: str = "" review_result: str = "" user_name: str = "" context_cache: dict = {}重点说明几个设计决策。messages字段用了Annotated加add_messagesreducer,这是LangGraph最基础但也最容易被忽略的机制。默认情况下,节点返回新状态会覆盖旧状态,也就是说你一个节点要返回历史对话的完整列表,否则上下文就丢了。add_messages的作用是自动做列表合并,新节点只需要返回新增的那几条消息,框架自动帮你把新旧消息拼在一起,按消息的message id去重。用人话说就是,每个节点不用再操心“我得把之前的聊天记录原封不动地交接给下一步”,框架替你干了。
intent和need_review这类标量字段就简单了,按覆盖逻辑走就行。注意我没有把draft_reply和review_result塞进messages里,它们属于流程元数据,不是直接给模型看的上下文。这个区分很重要,否则你会发现在agents里想让不同节点看不同字段,结果全部堆在一个大字典里,越用越乱。
2.2 Reducer机制:理解state的合并逻辑
我第一次用LangGraph就踩了个坑:自定义state字段时,子节点返回了新的dict,结果旧数据整个消失了。后来我才搞明白,LangGraph节点的返回值不是“补丁”,而是“替换”。除非你为字段声明了特殊的reducer,否则new value直接覆盖old value。
这个机制在设计时很反直觉,但它有一个好处——每个节点对状态的修改都是显式且无副作用的。如果你想让某个集合字段增量更新,就给它配reducer;如果你想保留历史值,就别傻傻让节点只返回新值。add_messages是最常用的reducer,实际业务中我还会自定义reducer来满足一些合并需求。
一个我实际用过的自定义reducer例子:
def merge_cache(old: dict, new: dict) -> dict: # 只更新新增的键,不清空旧缓存 merged = old.copy() merged.update(new) return merged class AgentState(BaseModel): messages: Annotated[list, add_messages] = [] context_cache: Annotated[dict, merge_cache] = {}这样设计的好处是节点之间共享临时数据时,不用每次把所有key都带上。比如检索节点把知识库命中的文档片段写进cache,生成节点再读出来用,彼此不干扰。如果你需要完全覆盖cache,那段代码注释里写上“全量替换”并新建一个临时key,别复用同一个字段。
我一直认为状态设计的核心原则是:只放那些必须在多个节点间流动的数据,能局部化的绝不全局化。很多初学者喜欢把所有中间结果都塞到state里,整个状态对象越来越大,排查问题的时候看到几千行JSON,根本不知道谁改了什么。
2.3 状态持久化:Checkpoint是Agent的“存档点”
LangGraph的checkpoint机制支撑起了两个进阶能力——持久化和人工介入。每次图执行经过一个节点之后,所有状态都会被存到checkpoint里。这个设计和单机游戏存档很像:你玩到一半存档,下次读档可以从那里继续。
我用的持久化后端是SqliteSaver,单机场景完全够用,性能也很稳。
from langgraph.checkpoint.sqlite import SqliteSaver memory = SqliteSaver.from_conn_string("agent_state.db") graph = workflow.compile(checkpointer=memory)有了checkpointer之后,每次执行时传入一个thread_id:
config = {"configurable": {"thread_id": "customer-12345"}}这个thread_id相当于你的存档槽位。同一个thread_id执行过的图运行历史都会被记录,进程重启后照样能恢复执行,也可以随时查询当前线程的状态。
其实因为checkpointer的存在,LangGraph的图执行才真正具备了“可中断、可恢复”的商业级可靠性。比如线上服务发版重启了,用户会话状态还在;或者人工审核等了15分钟,Agent还挂在那里等结果回复,这才是“下地干活”的底子。
3. 条件路由:让Agent在不同工作流之间自由切换
3.1 条件路由的意义与实现方式
条件路由解决的核心问题是:图跑到了某个节点,接下来该往哪走,不是写死的,而是根据当前状态动态决定。官方文档里把这叫做conditional_edges,我的理解就是在节点执行完后,调用一个路由函数,让它分析state,然后返回下一步要去的节点名称。
比如我的协作文档Agent,入口节点先做意图识别,根据识别结果决定下一步走“普通问答”还是“草稿生成与审批”。这个分支如果用Chain硬写,只能在代码外层做if-else,但分支内又需要子图调用,嵌套级别会变得很深,读代码的人尤其崩溃。
LangGraph让路由变成图结构里的一等公民,流程可视化直观得多。下面这段代码是核心逻辑:
def route_after_intent(state: AgentState) -> str: if state.intent == "faq": return "faq_answer" elif state.intent == "external_reply": return "draft_reply" else: return "clarify" workflow.add_conditional_edges( "intent_node", route_after_intent, { "faq_answer": "faq_answer_node", "draft_reply": "draft_reply_node", "clarify": "clarify_node" } )关键在于route_after_intent这个路由函数本身。它不产生任何业务动作,纯粹做状态分析。从state里读取判决依据,返回确定的节点名。注意,这里我不建议你用一长串if-else去调模型,路由里最好只做纯逻辑判断,凡是要做LLM推理的内容放在前面的意图识别节点里。这样路由函数的运行速度是毫秒级的,而且反应迅速。
3.2 结合业务细节设计路由的几种常见模式
模式一:metadata驱动路由
我在意图节点里除了识别用户的提问意图,还识别附加属性,比如用户类型、敏感等级、需要审批等,把这些信息放到state的字段里。后面路由就是纯查字段,秒回。
模式二:多分支并行或条件分支
LangGraph支持一个节点出口连多条边,条件边不影响普通边。我常用的场景是:节点A执行完后,既无条件边路由到不同的处理节点,也有普通边同步更新一些状态。比如草稿生成完之后,一边可以走人工审核,另一边把草稿内容写入日志节点。这样的设计让图的表达能力很强,但如果你没有强制可视化图结构,复杂度也容易失控,我建议你用langgraph自带的get_graph().print_ascii()或者在线绘图工具画一下图再决定怎么接边。
模式三:子图嵌套复用
如果你的Agent有多个入口都需要经过同样的检索、重写、审核流程,可以把这一段封装成子图,再塞到父图的一个节点里。父节点调用子图,子图内部自己的状态是隔离的或者按需穿透。模板化的复用能避免同样的连线画好几遍,改动也只需要动一处。
3.3 路由中的错误处理与兜底
状态路由不像API网关那样,入口参数不符合预期就直接返回400报错。Graph执行时节点内部可能异常,也可能路由函数收到的state跟你预期的不一样。比如某个节点没写intent字段,intent还保持默认值,路由就走了“clarify”。这个流程虽然不会崩溃,但是会让你困惑,排查也比较费劲。
我踩过最典型的一个坑是:路由函数中访问了state里不存在的key,直接抛KeyError。后来我养成了一个习惯——在state模型里给所有路由要参考的字段都设好默认值,比如intent默认"general"、need_review默认False。路由函数顶部再加一个防御性判断:
def route_after_intent(state: AgentState) -> str: intent = getattr(state, "intent", "general") ...这看起来多此一举,但在Agent系统里,节点的执行顺序偶尔会因为默认配置调整而改变,多一点防御就能少一次深夜急诊。
还有一个需要特别注意的:路由函数的返回值必须在映射表里。如果你返回了一个没在第三个参数里配置的节点名,LangGraph执行时会直接报错,提示你找不到节点。这个错误信息还算友好,但我在自动化测试里还是专门加了一条用例来检验所有路由路径的完整性。
4. 人工介入:让AI停下来,等一个“人”的决定
4.1 interrupt机制:从“全自动”到“人机协同”
很多人做Agent都有一个执念——全自动。但真上生产的时候,AI直接对外发送消息这件事,风险却大得多。有没有什么机制可以在关键时刻把AI“冻结”住,等人工拍板再继续?LangGraph给出的标准方案就是interrupt。
interrupt的使用条件有两个:第一,必须有checkpointer(没有保存现场,中断恢复无从谈起);第二,图执行到这里会抛出一个特殊异常,把控制权交回调用方,状态被冻结在graph的最后一个节点之后。
从调用方的视角来看,最核心的写法是这样的:
from langgraph.types import interrupt, Command # 在节点内部 def draft_node(state: AgentState): draft = generate_draft(state) # 这里的decision是我们要中途要回的值 review_decision = interrupt({ "type": "review_required", "draft": draft, "reason": "external_reply" }) if review_decision.get("action") == "approve": return {"draft_reply": draft, "review_result": "approved"} else: return {"draft_reply": draft, "review_result": "rejected", "feedback": review_decision.get("feedback", "")}这段代码的含义是:图执行到draft_node时,先把生成的草稿发给外部调用方(比如Web API),然后立即挂起。外部人工审核完成后,调用方通过graph.invoke(Command(resume={"action": "approve", ...}), config=config)恢复执行。此时draft_node的interrupt相当于有了返回值(我们传入的resume数据),然后继续往下走。
4.2 结合FastAPI和LangGraph实现人工审核接口
要真正应用到生产环境,最好还是用FastAPI把Graph包裹起来,对外暴露Web接口,让审核员通过前端页面处理待审任务。核心接口有两个:提交请求接口和人工决策接口。
回传并恢复执行的代码是这样:
@app.post("/agent/{thread_id}/review") async def submit_review(thread_id: str, decision: ReviewRequest): config = {"configurable": {"thread_id": thread_id}} # 恢复执行,把审核结果作为中断的返回值传回 result = await graph.ainvoke( Command(resume=decision.model_dump()), config=config ) return {"status": "resumed", "result": result}这里有一个细节值得留意:当graph被interrupt挂起时,调用方如果再次用同一个thread_id执行graph.invoke(None),不会从第一条边开始重新执行,而是从上次中断的地方继续运行。这是LangGraph结合checkpointer之后自动完成的。如果你想“重新开始”而不是“继续”,需要用新的thread_id,或者显式更新checkpoint的配置。
State的设计中,draft_reply和review_result这两个字段,就是为了支持人工介入的。AI生成草稿后,草稿内容写进state;人工的审核结论与意见也写进state;下游执行节点拿到这些数据再决定发送还是打回重写。人机协同里最关键的一点是:所有交接信息必须落地到显式的state字段中,不能只藏在某个临时变量里。
4.3 复杂人工审批流的两种变体
第一种变体是“打回重写”。人工驳回时,不只是把review_result标成rejected,还要把审核意见feedback这个字段写进去。生成节点再次运行时判断:如果review_result是rejected,则根据feedback调整内容重写一次。相当于人给AI一次修正的机会。
def draft_node(state: AgentState): draft = generate_draft(state) decision = interrupt({ "type": "review_required", "draft": draft, "reason": "external_reply" }) if decision.get("action") == "reject": state.context_cache["feedback"] = decision.get("feedback", "") state.review_result = "rejected" return {"review_result": "rejected", "feedback": decision.get("feedback", "")}第二种变体是“多人会签”。某些高权限操作需要不止一个人批准,我不建议在LangGraph里用嵌套interrupt硬写,更稳妥的做法是把“等待多人审批”这种任务完全放在Graph外部的业务系统里,Graph只负责发起审批任务。等外部系统汇总所有人的决策之后,再调用那个/review接口恢复执行。
这两种变体的取舍逻辑是:如果人工介入时间短、状态简单,放进LangGraph里用interrupt比较方便;如果人工介入跨度长达几天,涉及复杂的工单流转和多人审批,那就该交给专业的流程引擎去处理。LangGraph是Agent工作流引擎,不是万能的业务流程系统。别硬套。
4.4 人工介入的常见失败模式与排查
interrupt机制在初学时容易让人困惑的地方是:如果你忘了在compile时配置checkpointer,graph会直接抛错提示需要checkpointer;如果你恢复了但传入的数据结构与节点内预期的不一致,例如传了个字符串而不是dict,interrupt拿到之后照常处理,但下游节点的逻辑就崩了。所以建议每次人工介入之前,专门写一个状态校验函数。
我在实际跑流程时遇到过一个问题:人工审核页面点击通过后,Agent没有继续往下执行发送动作,而是卡在原地。后来排查下来,是config里的thread_id在前后端传递时被网页框架做了URL编码处理,导致后端拿到了一个变体字段,无法对应当前checkpoint。人工介入相关的链路设计,需要把thread_id当作一等参数从请求入口一直传到恢复调用,中间别让任何框架做奇奇怪怪的清洗。
5. 完整实操:从状态定义到人工介入串联整个流程
5.1 整图构建与代码骨架
在具体落地时,我将整个Agent拆成五个节点:intent_node(意图识别)、faq_answer_node(普通问答)、draft_node(草稿生成+人工介入)、send_node(对外发送)、clarify_node(追问澄清)。工作流编译如下。
from langgraph.graph import StateGraph, START, END # 1. 初始化图 builder = StateGraph(AgentState) # 2. 添加节点 builder.add_node("intent_node", intent_node) builder.add_node("faq_answer_node", faq_answer_node) builder.add_node("draft_node", draft_node) builder.add_node("send_node", send_node) builder.add_node("clarify_node", clarify_node) # 3. 普通边:从START到intent_node,从草稿审批通过/驳回后到重写节点 builder.add_edge(START, "intent_node") builder.add_edge("intent_node", "draft_node") builder.add_edge("draft_node", "send_node") builder.add_edge("faq_answer_node", END) builder.add_edge("clarify_node", END) # 4. 条件路由:根据意图分流 builder.add_conditional_edges( "intent_node", route_after_intent, { "faq_answer": "faq_answer_node", "draft_reply": "draft_node", "clarify": "clarify_node" } ) # 5. 人工介入后的条件分支 def route_after_review(state: AgentState) -> str: if state.review_result == "approved": return "send_node" return "draft_node" builder.add_conditional_edges( "draft_node", route_after_review, { "send_node": "send_node", "draft_node": "draft_node" } ) builder.add_edge("send_node", END) # 6. 编译并绑定checkpointer graph = builder.compile(checkpointer=memory)看起来代码很简单,但有几个点值得反复强调。draft_node这一个节点承担了“生成草稿”和“人工介入”两件事,原因是LangGraph的interrupt机制天然把节点在中断点隔成了两段——前半段是调用interrupt之前,后半段是人工返回之后。如果你把它拆成两个节点,反而需要额外的state字段来记录“这个草稿是不是已经人工审核过了”这种会话状态,结构反而更啰嗦。
5.2 关键节点的实现细节与易错点
意图识别节点是整张图的入口,它的输出质量直接影响后面所有分支走向。我在这里用了一个带结构化输出的prompt,而不是纯粹的字符串生成。这样意图、是否外部回复、用户姓名等都能直接落到state字段里。
class IntentOutput(BaseModel): intent: str = Field(description="faq/draft/clarify") need_review: bool = False user_name: str = "" def intent_node(state: AgentState): prompt = f""" 你是客服意图识别器。根据用户消息,判断用户意图。 如果用户问普通问题,intent=faq。 如果用户请求发送外部邮件或对外回复,intent=draft。 如果信息不足,需要追问,intent=clarify。 用户消息:{state.messages[-1].content} """ llm = ChatOpenAI(model="gpt-4o-mini") structured_llm = llm.with_structured_output(IntentOutput) result = structured_llm.invoke(prompt) return {"intent": result.intent, "need_review": result.need_review, "user_name": result.user_name}意图识别节点里必须防范的坑:带格式的prompt输出不稳定。我实测过很多种prompt写法,最稳定的是给一个极简例子+明确各个字段的语义。别让模型自由发挥intent值,那是给自己埋定时炸弹。
5.3 结合FastAPI的完整调用链演示
外部调用方要支持的接口只有两个:启动Agent和提交人工决策。加上FastAPI之后整体调用链是:用户POST /agent/start -> FastAPI创建thread_id -> graph.ainvoke启动执行 -> 执行到interrupt后返回到FastAPI -> 前端页面显示pending review -> 审核员POST /agent/{thread_id}/review -> graph.ainvoke恢复执行 -> 正常跑完后续节点。
启动接口的代码骨架:
@app.post("/agent/start") async def start_agent(req: UserRequest): thread_id = str(uuid.uuid4()) config = {"configurable": {"thread_id": thread_id}} # 用异步调用避免阻塞 result = await graph.ainvoke( {"messages": [HumanMessage(content=req.message)]}, config=config ) return { "thread_id": thread_id, "result": result, "status": "completed_or_interrupted" }执行到interrupt时会抛出一个GraphInterrupt异常,但我测试时发现不用捕获它也没事。graph.ainvoke会被中断并正常返回,返回的结果里有一个字段能看到当前挂在哪个节点。你不能试图让“中断”变成一个干净的结束状态,因为Agent任务还没执行完,真正的履约动作要等人工审核通过之后再继续。所以毫无疑问,前端在收到interrupt信号时应该把界面切换到“正在等待人工审核”。
5.4 日志追踪与可观测性建议
Agent系统最难的就是调试。节点多、状态复杂、还有中断恢复,一旦出错很难定位。我强烈建议在Graph外面包一层日志埋点,至少记录三类信息:每次invoke操作之前的输入状态、每次节点执行完之后的输出状态摘要、人工介入的完整决策记录。不必全量打印state,生产环境里几千条历史消息打全量日志会撑爆存储。
LangGraph本身提供了langfuse之类的回调集成,但即使不用第三方工具,自己加一个回调函数也能解决70%的排查需求。回调里能拿到node名称和输入输出state,足够了。这个环节千万别偷懒,Agent在真实环境里不可预测的走向远比你想象的丰富得多。
6. 常见问题、踩坑记录与经验速查
6.1 高频问题排查表
| 问题现象 | 根本原因 | 排查/解决方案 |
|---|---|---|
| 节点返回后state里messages丢失 | 忘了用add_messages reducer,返回结构覆盖旧列表 | 字段用Annotated[list, add_messages]声明 |
| interrupt被调用时直接报错,提示需要checkpointer | 编译图时没有绑定任何checkpointer | 显式传入SqliteSaver或MemorySaver |
| resume后节点收到的值是空 | 恢复调用时用了graph.invoke而不是Command(resume=...) | 改为graph.invoke(Command(resume=payload), config=config) |
| 同样thread_id第二次执行不会从头开始 | checkpointer恢复模式的工作机制 | 要想重新跑就用新thread_id |
| 条件路由返回了不存在映射表的节点名 | 映射表遗漏了新节点 | 在映射表补上,写自动化测试遍历所有路由结果 |
| 人工审核通过后节点没有继续 | thread_id被中间层篡改 | 排查请求传递过程中是否有编码/清洗逻辑 |
| 状态字段越来越多,难以定位问题 | state里塞了太多一次性变量 | 只保留跨节点流动的数据,节点局部数据用局部变量 |
6.2 我在实操里踩过的三个隐蔽坑
第一个坑和Pydantic的版本行为有关。早期LangGraph的AgentState用TypedDict不会有问题,但一旦字段多起来,比如超过20个,TypedDict的隐式类型转换会让你崩溃到摔键盘。改成Pydantic后,字段校验可以明确控制,关键是团队成员看到代码也能立刻知道哪些字段是必须的。
第二个坑是并发执行。我的服务里同一个用户id连续发了两条消息,结果两个请求各自创建了不同的thread_id,导致上下文完全割裂。后来我在生产层的session管理里做了session_id到thread_id的一致性映射,才杜绝这个问题。LangGraph本身不做这种会话关联,你必须自己落实会话粘性。
第三个坑是interrupt之后又做了外部API调用。比如人工审核通过了,send_node需要调用外部邮件API发送邮件,这个API调用失败怎么办?我是这样处理的:send_node内部先用try-except包住外部调用,失败则返回一个专门的字段,让路由把它导回重试节点而不是直接结束。真实系统的健壮性跟你多想了多少异常路径直接相关。
6.3 实用经验清单
调试LangGraph有一个极其好用的功能:graph.get_state(config)。用thread_id就能看到当前线程执行到哪个节点、state长什么样、下一步准备去哪。调试人工介入时配合graph的resume钩子,能手动模拟任意中断和恢复场景。这个工具能帮你快速定位状态没有正确更新的问题,强烈推荐每个用LangGraph的人都学会。
另外state更新一定要遵循一个原则:节点不要修改传入的state对象本身,而是返回一个新的dict,框架负责合并。有些同学图方便直接改state属性,比如state.user_name = "xxx",这在很多情况下不会生效,因为框架不是拿你修改后的那个对象去驱动后续节点的。所有变更都要放进返回值里,这是LangGraph最核心的使用范式,没有例外。
7. 后续扩展方向与实战扩展建议
我做这套协作文档Agent之后,立刻发现了几个可以继续扩展的方向,这里一并分享。
第一个是增加多轮澄清的循环机制。当前设计里clarify_node问完用户就结束了,但如果用户回复还是信息不足,应该允许它再次回到意图识别节点。解决办法很简单:给clarify_node加一条条件边回intent_node,并限制最大澄清轮数。这个改动能让Agent更像是真的会跟人来回沟通而不是一次性的僵硬问话。
第二个是接入长期记忆。SqliteSaver能存执行状态,但跨不同thread_id的长期用户偏好、历史交互摘要,还是得靠向量数据库或者外部记忆层。LangGraph有一个MemoryStore的概念,可以先研究一下再说。实际业务里,用户上次说“我更喜欢简洁回答”,下次新会话还能记住,这会对体验有质的提升。
第三个是把子图抽出来做团队级复用。我这次把问答检索流程和草稿审批流程都独立成了子图,其他项目直接引用就好。子图之间的state可以通过Schema显式定义Map关系,避免互相污染字段。
我个人在实际操作中的体会是,LangGraph的上手门槛不在API本身——图、节点、边、状态这些概念用半天就熟了——难点在于你是否能在真实项目中清晰地区分“什么状态必须跨节点流动”和“什么流程需要人为踩刹车”。很多团队用LangGraph写出的Agent看起来像在写流程图,可一遇到复杂场景就失控,根子还是流程设计没想清楚。这篇文章里那套“先识别意图,再决定是否走人工审核”的思路,希望给正在做Agent落地的你一点点参考。
最后再分享一个小技巧:写完Graph之后,别急着接外部API,先用get_graph().draw_mermaid()把可视化图拉出来看一眼。如果某两块逻辑之间缠了很多条边,大概率可以简化成一条带条件路由的边。图越简单,生产事故越少。