资讯动态

用LangGraph构建可控Agent:从源头拦截LLM幻觉的工程实践

发布时间:2026/10/21 13:01:16 来源:尧图企业网站定制
1. 先搞明白LLM幻觉为什么在Agent场景里更致命先抛一个我自己踩过的场景。某次做一个企业内部的知识库问答Agent用户问“某项目的上线日期是哪天”模型非常流畅地答出“2024年6月30日”还附带了一串听起来很合理的上下文。结果实际上那个项目压根还没排期。数据库里没有检索知识库里也没有模型纯粹是根据问题里的相关词“编”了一个日期出来。这在纯对话场景里顶多算是一次错误回答但在Agent场景里问题会被放大好几倍——因为Agent拿到这个错误答案之后还会继续做后续动作查排期、写周报、生成任务、通知其他人。一个幻觉会顺着Agent的动作链条一路传导下去越滚越大。这就是为什么大家现在聊Agent时几乎绕不开“可控”这两个字。LangGraph能在这个话题里站到C位不是因为它能消除模型产生幻觉的能力而是它给了你一套“在关键步骤上拦截幻觉”的工程手段。这篇教程我不会去念API文档而是用我实际做过的一个技术问答Agent当例子从框架设计、代码实现到翻车现场完整拆一遍到底怎么用LangGraph把Agent的行为管住让幻觉在源头被过滤掉而不是等它污染了整条链路之后再来收拾。先说说适合谁看如果你之前用过LangChain或者裸写过ReAct风格的Agent但对它的不可控感到头疼如果你也遇到过“模型连续编造工具参数”“Agent在错误分支里死循环”“输出格式千奇百怪”这类问题那这篇内容应该能帮到你。没接触过LangGraph也没关系核心概念我会用大白话讲清楚。1.1 三种常见的幻觉形态你对号入座一下我在实际项目里见到的幻觉基本可以归成三类。第一类是事实性幻觉就是编造不存在的信息。最常见的是编造日期、人名、URL、文档编号。模型没有对应的记忆但为了让你满意它会把所有空格都填上。这类幻觉在知识库问答里最危险因为用户往往无法在第一时间辨别真伪。第二类是指令性幻觉就是你要求模型“如果没有确切答案就拒答”它会答应得好好的但真正操作起来还是选择“自由发挥”。不是模型想骗你而是Prompt里的约束在生成时会被概率性地忽略尤其是当拒答会让回答看起来“不完整”时模型反而倾向于补全。第三类是工具参数幻觉这个在Agent场景里最具破坏性。模型需要调用工具时它会根据对话上下文猜测参数值比如猜一个文档ID、猜一个时间范围、猜一个不知道从哪来的筛选条件。工具拿这些参数去执行返回的可能是一堆空数据或者错误数据关键是这个错误链路会让下游节点误以为“动作已经完成”。我在做第一个Agent原型时三种幻觉全踩了一遍。当时最崩溃的是工具参数幻觉——模型调用一个查订单的接口自己编了个订单号接口刚好返回“无数据”而不是报错于是Agent就朝着“无数据”这个错误前提继续生成下一步结论。这就是典型的Agent失控现场。1.2 为什么单纯靠Prompt压不住幻觉在对幻觉还没有很深的感知时我看很多团队的做法是往系统提示词里塞一堆“你必须基于检索内容回答”“不要编造信息”“不确定就承认不确定”之类的话。刚开始确实有点效果尤其是加了示例之后看起来像那么回事。但只要对话轮数一多、任务一复杂问题就回来了。原因在于LLM的生成本质是一个概率过程每一次生成都是对下一个Token的概率采样。提示词里的约束会改变概率分布但不可能把错误Token的概率压到零。只要某个错误Token的概率不是绝对为零它在长文本、长链路中就有可能出现。更麻烦的是Agent会把前一轮的错误输出当成下一轮的输入上下文这个错误信号被不断强化最后形成一个自洽但完全错误的“事实链”。所以对抗幻觉不能只在“模型层面解决”而要在“工程层面解决”。核心思路是不要让模型自由决定整个流程而是把流程拆成节点在节点之间加校验、加条件判断、加兜底逻辑。模型负责它擅长的事——理解语义、生成文本、推理下一步——但流程走向、动作合法性、输出格式都由代码来控制。这正好就是LangGraph的设计哲学。1.3 LangGraph“可控”的本质把模型从驾驶员变成乘客说得直白一点LangGraph把Agent从“模型自己开完全程”改成了“代码负责开车模型负责在指定路口做决策”。这种设计不是削弱模型的能力而是把模型放在它最适合的位置上。整条任务链路被拆成多个节点每个节点只做一件事比如“理解用户意图”“决定是否调用工具”“生成最终回答”。节点和节点之间的跳转关系由图来定义而不是由模型自由发挥。模型只能在下一次跳转时选择“走哪条边”但它不能自己发明新的边也不能跳过某些必经节点。对于防幻觉来说这套结构的价值是决定性的你可以在“生成最终回答”这个节点前面加一个强制的校验节点——检查它引用的来源存不存在、格式对不对、有没有超出允许的范围。代码看到校验不过可以直接把流程引到拒答分支而不是让模型硬着头皮继续编。这种拦截在任何提示词策略里都做不到。2. LangGraph里的几个核心概念拿Agent场景对照着讲很多人第一次接触LangGraph时被它的术语搞晕了——StateGraph、Node、Edge、Conditional Edge、Checkpointer。其实这些概念的底层逻辑非常朴素就是一个带状态流转的有向图。下面我把每个概念都对应到Agent的实际场景里这样更好消化。2.1 StateAgent的“工作记忆”State在LangGraph里就是两个角色一是所有节点之间传递信息的载体二是整个流程的“当前快照”。你可以把它理解成一个不断被更新的字典。糟糕的地方在于很多刚上手的人不知道State该怎么设计习惯把所有字段都塞进去结果每个节点都去改它改到最后都不知道某个字段是被哪个节点覆盖的。在设计State时需要有一点“克制”。原则是只放节点之间需要互相传递的数据节点内部私有的变量不要放进去。我后面会给出一个具体的State设计。LangGraph的State还有一个进阶功能——Reducer。默认情况下新值会覆盖旧值但你可以通过Reducer来控制“同一个字段多次赋值时怎么合并”。最常见的场景是对话历史字段每次新消息来时要追加而不是覆盖所以会用add_messages这个Reducer来把新旧消息合并成一个列表。2.2 Node最小执行单元节点就是一个普通函数输入是当前的State输出是一个字典表示对State的增量更新。一个节点不能篡改别人的字段它要么新增字段要么修改自己负责的字段。这个隔离机制很重要可以避免“某个节点顺手改了别人的数据导致链路出错”这类问题。在我的项目里节点按职责划分成三类决策类节点比如识别意图、工具类节点比如检索知识库、生成类节点比如输出最终回答。每个节点只做一件事这样出问题时定位特别快。有一点容易踩坑Python里的字典是可变对象如果A节点的输出里有一个列表字段直接把这个列表传给下一个节点下一个节点修改它时A节点的数据也可能被一起改动。你不想在Agent里调试了半天发现是Python可变对象别名问题的话可以考虑在节点函数内部对数据进行浅拷贝。2.3 Edge与条件路由流程的“方向盘”Edge就是从一个节点到另一个节点的连线。普通Edge保证走完A节点后必然走到B节点。条件路由Conditional Edge则更加灵活——它让你定义一个路由函数函数读一下State里的某个字段根据结果返回下一跳要去的节点名称。这几乎是LangGraph控制Agent行为最关键的手段。幻觉判定、意图分流、兜底策略全都靠条件路由来实现。模型做完决策之后代码来判定这个决策合不合法、要不要放行、要不要重定向。这就是“可控”两字的直接体现。2.4 Checkpointer与interrupt断点续跑和人工介入Checkpointer负责保存State快照这样Agent可以在某个节点执行到一半时暂停下来保存当前所有状态之后从同一位置继续执行。这在超长任务、崩溃恢复、人机协同场景里都是刚需。interrupt则更有意思。它可以在某个节点执行之前暂停整个图等待外部输入。典型场景是Agent准备执行“发送邮件”“提交订单”这类敏感动作时流程暂停弹给用户确认用户确认之后流程再继续。这恰好也是“可控”在操作层面的体现——防止Agent盲目执行高风险动作。3. 实战从零构建一个“知识问答Agent”应用场景我设定为内部技术知识库问答。用户向Agent提技术问题Agent从知识库检索相关内容基于检索结果生成回答。如果问题不在知识库覆盖范围内Agent必须明确拒答不能自己编造。这个场景虽然简单但正好覆盖了幻觉干预的全部关键节点。技术栈为LangGraph LangChain的模型接口封装。完整代码我会分段贴出来并说明每一段在做什么、为什么这么写。实际开发中只抽取其中某一小节作为参考也可以。3.1 先定义状态为“可控”打好底子我先定义Agent的Statefrom typing import Annotated, List, Optional from langgraph.graph.message import add_messages from typing_extensions import TypedDict class AgentState(TypedDict): # 对话历史追加式写入 messages: Annotated[list, add_messages] # 用户当前问题从最后一条消息里提取 current_question: str # 意图识别结果knowledge / chitchat / unknown intent: str # 检索到的知识库文档列表 retrieved_docs: List[dict] # 检索是否命中yes / no retrieval_hit: str # 最终回答内容 final_answer: Optional[str] # 回答中引用的来源ID列表用于事后校验 cited_source_ids: List[str] # 重试次数防止死循环 retry_count: int字段设计原则很清晰messages是公共对话上下文用add_messages做追加intent是决策字段retrieved_docs是工具节点的产物final_answer是生成节点的产物cited_source_ids专门给幻觉校验节点用retry_count用来控制循环上限。有一个细节要注意不要让State里塞太多大数据字段。比如检索返回的完整文档内容存retrieved_docs就好了没必要把所有原文都塞进去否则每一步节点之间传递State时数据量会大得吓人。3.2 定义工具知识检索是“Grounding”的来源这个Agent的核心工具是search_knowledge入参是一个查询词返回是相关文档列表。我这里用向量检索演示调用某个嵌入模型和向量库的简化写法def search_knowledge(query: str) - List[dict]: # 实际项目中这里会调用向量数据库 # 为了方便演示这里直接返回两条模拟数据 results [ { id: doc-001, title: LangGraph状态管理指南, content: State用于在节点之间传递信息..., score: 0.87, }, { id: doc-002, title: LangGraph条件路由详解, content: Conditional Edge会根据State字段选择下一个节点..., score: 0.81, }, ] return results这里犯过一个很典型的错误最开始我把检索结果在节点里直接拼成了字符串塞给模型却把原始来源ID弄丢了导致后面幻觉校验时根本无从查起。正确做法是结构化的文档列表与引用ID同时保留这样才能在生成之后把它们关联起来。还有一个重要细节检索工具能不能“命中”必须显式判定不能拿空列表当结果继续跑。你需要有一个字段比如retrieval_hit来标记检索是成功还是失败这个字段会被后续的条件路由读取。3.3 节点实现控制点逐个拆解第一个节点是意图识别。它的作用是从用户输入里判断问题类型决定后续流程往哪走。这里模型依旧在做语义判断但它的决策空间已经被“关”到三个选项里了def understand_intent(state: AgentState) - dict: question state[current_question] # 用结构化输出限定决策空间防止模型答非所问 class IntentOutput(BaseModel): intent: str # 只能是 knowledge / chitchat / unknown structured_llm llm.with_structured_output(IntentOutput) result structured_llm.invoke( f用户问题是{question}\n 请判断这个问题是否属于企业内部技术知识库的覆盖范围。\n - knowledge用户询问具体技术概念/用法知识库中很可能有答案\n - chitchat闲聊、问候、非技术问题\n - unknown无法判断归属 ) return {intent: result.intent}第二个节点是检索。它只在意图为“knowledge”时执行def search_node(state: AgentState) - dict: docs search_knowledge(state[current_question]) if not docs: return {retrieved_docs: [], retrieval_hit: no} # 按相关性排序后取前K个 docs.sort(keylambda x: x[score], reverseTrue) top_docs docs[:3] return { retrieved_docs: top_docs, retrieval_hit: yes, }这里我故意保留了retrieval_hit为“no”的分支这是防幻觉的关键检索不到内容时绝不往下生成直接走拒答分支。第三个节点是回答生成。它必须同时满足Grounding和结构化输出两个约束。我让模型严格基于检索内容来写回答并且返回带来源ID的结构化对象def generate_answer(state: AgentState) - dict: context \n\n.join( f[{doc[id]}] {doc[title]}\n{doc[content]} for doc in state[retrieved_docs] ) class AnswerOutput(BaseModel): content: str cited_source_ids: List[str] structured_llm llm.with_structured_output(AnswerOutput) result structured_llm.invoke( f请基于以下检索资料回答用户问题。\n\n f用户问题{state[current_question]}\n\n f检索资料\n{context}\n\n 要求回答必须严格基于检索资料。 在cited_source_ids中列出你实际引用的文档ID。 ) return { final_answer: result.content, cited_source_ids: result.cited_source_ids, }第四个节点是幻觉校验。这个节点完全由代码执行不涉及模型专门用来拦截那些“看起来合理但其实没有来源”的内容def guard_check(state: AgentState) - dict: cited set(state[cited_source_ids]) allowed {doc[id] for doc in state[retrieved_docs]} # 引用的ID必须全部来自检索结果 if not cited: return {retry_count: state[retry_count] 1, verdict: reject} if not cited.issubset(allowed): return {retry_count: state[retry_count] 1, verdict: reject} return {verdict: pass}第五个节点是拒答节点。它在意图识别失败、检索结果为“no”或重试次数超限时触发只做一件事情就是明确拒答def reject_node(state: AgentState) - dict: return { final_answer: ( 抱歉我无法基于当前知识库可靠地回答这个问题。 建议将问题补充得更具体或者先确认问题是否在知识库范围内。 ), cited_source_ids: [], }3.4 组装图条件路由把流程“锁”死节点定义完之后用StateGraph把它们连起来from langgraph.graph import StateGraph, START, END from langgraph.checkpoint.memory import InMemorySaver graph StateGraph(AgentState) # 添加节点 graph.add_node(understand_intent, understand_intent) graph.add_node(search_node, search_node) graph.add_node(generate_answer, generate_answer) graph.add_node(guard_check, guard_check) graph.add_node(reject_node, reject_node) # 入口边 graph.add_edge(START, understand_intent) # 条件路由1根据意图分流 graph.add_conditional_edges( understand_intent, lambda state: state[intent], { knowledge: search_node, chitchat: reject_node, unknown: reject_node, }, ) # 条件路由2检索命中才继续生成否则拒答 graph.add_conditional_edges( search_node, lambda state: state[retrieval_hit], { yes: generate_answer, no: reject_node, }, ) # 校验通过才放行否则拒答 graph.add_conditional_edges( guard_check, lambda state: state[verdict], { pass: END, reject: reject_node, }, ) graph.add_edge(reject_node, END) # 编译 agent graph.compile(checkpointerInMemorySaver())这个图的控制逻辑是这样的意图不对直接拒答检索没命中直接拒答校验不通过直接拒答。模型在每个分支都只能做有限选择没有一条路径允许“无来源”的回答流出。checkpointer我用了内存版演示实际生产环境建议换成持久化存储比如SQLite或Redis后端。它是断点续跑和人机协同的基础设施后面会细说。3.5 加上人工审批更进一步的“可控”对于某些敏感场景比如发送邮件、提交工单、执行删除动作我建议在动作执行节点之前加一个interrupt。LangGraph的interrupt()函数会暂停图的执行把控制权还给外部程序等到外部确认之后再继续执行from langgraph.types import interrupt def request_approval(state: AgentState) - dict: # 暂停执行把待审批信息抛给外部 response interrupt({ action: send_email, to: personexample.com, subject: 项目周报, body: state[final_answer], }) # 外部确认后response为审批结果 return {approval_status: response[approved]}这个机制的价值在于Agent仍然负责生成和推荐动作但最终的执行权在代码和人这里。模型可以建议代码可以拦截。这正是“可控Agent”与“全自动Agent”的明显分界线。4. 防幻觉的硬性策略把错误拦截在输出之前上面实战里的guard_check节点只是第一道防线。我在后来迭代项目时总结了一套组合策略整理出来分享给大家。4.1 Grounding答案必须锚定在证据上Grounding原则是模型生成的任何事实性陈述必须在输入上下文中找到对应证据。没有证据的陈述就不应该出现。实际操作中我做了三层第一层是检索到资料时Prompt里强制说明“回答只能使用检索资料中的信息不要补充资料之外的事实”。这给模型一个最基本的边界。第二层是生成回答时附带引用ID要求模型把用到的每个事实点对应到文档ID上没有依据的输出直接标记为无效。第三层是代码层校验校验引用ID是否在允许范围内就是guard_check做的事。三层叠加可靠性比单纯靠Prompt提升了好几个量级。养成习惯无论用什么框架只要让模型输出内容就要有对应的证据清单并且证据必须可验证。4.2 结构化输出与校验把格式问题挡在接口层模型输出格式不稳定是Agent开发里最常见的问题之一。解决方案不是事后找模型修正格式而是从一开始就用结构化输出约束。LangChain的with_structured_output是一个比较顺手的工具它会把Pydantic模型的定义传给模型并让模型直接按模式生成JSON这个过程有一套完整机制做保障。我使用时的做法是定义一个严格的Pydantic模型把所有必填字段和不许为空的约束写清楚然后再加一层自定义校验逻辑。class AnswerOutput(BaseModel): content: str cited_source_ids: List[str] field_validator(cited_source_ids) classmethod def check_sources(cls, v: List[str]) - List[str]: if not v: raise ValueError(必须至少引用一个来源ID) return v这个方法的效果比较明显。早期我试过在生成后用正则去解析模型输出那种方式极其脆弱模型一旦在JSON前后加了额外说明文字就会被误判最终放弃。后来改成结构化输出之后格式问题基本消失了。4.3 校验即使通过大模型仍可能出错即使结构校验通过了模型依然可能在语义层面胡编乱造比如把检索到的内容与自己的“常识”掺在一起输出。这是结构化校验解决不了的。目前我应对这一类问题的主要方式是把事实性回答限定在“摘录组装”层面。Prompt中明确指示“回答中所有定义性语句应尽量沿用原始资料中的行文表述不要自行改写或扩展”同时温度参数调低一些比如temperature0.1减少输出随机性。这样虽然牺牲了一点自然度但换取的是可靠性的大幅提升。4.4 工具参数也必须有“防幻觉”机制参数幻觉在Agent里还有一个防不胜防的表现模型在你规定好“使用哪些工具、传什么参数”的情况下仍可能编造一些不存在参数值。我的经验是两步走。第一步在工具定义里给每个参数加上严格约束比如枚举值限制、格式说明、默认值设置。第二步在工具调用前加一个“参数校验节点”用代码检查工具收到的参数是否符合预定义的模式不合法就重新要求模型生成参数或者直接拒答。def validate_search_params(params: dict) - bool: if not params.get(query) or len(params[query].strip()) 2: return False if limit in params and not (1 params[limit] 10): return False return True一句话总结凡是模型要接触外部系统的地方都必须经过一道“参数合法性检查”否则就不要让工具执行。4.5 常见防幻觉策略对照表策略控制层级解决的问题实现方式侧重点Grounding提示提示词层无来源的“自由发挥”Prompt强制要求引用检索资料软约束需配合其他方式引用来源ID输出层无法验证的事实性陈述要求模型输出引用ID清单为校验提供依据结构化输出格式层输出格式不稳定Pydantic with_structured_output让输出可程序化处理参数校验工具层工具调用参数被“伪造”调用前检查参数合法性拦截错误动作代码层校验逻辑层引用ID非法或缺失图节点中用集合运算校验硬拦截强制拒答温度控制模型层随机性过大导致“发挥过度”调低temperature降低概率性错误这六层不是互斥的实际项目要叠加使用。我的经验是越靠近下游、越接近真实动作的环节越需要硬校验代码层、参数层因为那是幻觉扩撒到外部世界的最后一道防线。5. 实战中踩过的坑LangGraph避坑手记这一部分分享一下实际开发中最常遇到的几个问题和排查思路按频率排序。每一个都是真实踩过的。5.1 Agent陷入无限循环症状Agent在一个节点和另一个节点之间反复横跳直到递归限制报错。原因通常是条件路由函数写出了“既不是A也不是B”的意外返回值而路由表里没有兜底分支。定位方法给每个节点入口加日志输出当前State的关键字段重点排查条件路由用的字段是否和预期一致。规避方法每个add_conditional_edges的映射字典都加上一个default键指向拒答节点。再在路由函数里返回前先打印返回的键值可以免掉大量默默跳错的场景。另外在编译图时设置递归限制参数如recursion_limit10超过指定步数就自动中断。爆发事故后还能溯源也算是一种“兜底”。5.2 模型生成工具参数时幻觉症状模型调用工具时使用了不存在的ID或与上下文完全无关的参数值。原因之一是模型为了“完成对话”会顺着上下文的语境去“补全”缺失信息。第二个原因是在一些框架里如果工具没有显式声明参数来源模型就会自行猜测。检查方向严格定义工具参数把每个参数的类型、含义、范围写清楚在工具调用前增加参数校验如果参数不合规触发一个“补充信息”节点向用户要真正需要的参数而不是让模型猜。5.3 条件路由误判导致走了错误分支症状模型把“知识库之外的问题”错误识别成“knowledge”导致检索后用一堆无关资料去强行编造回答。原因意图识别依赖于模型的语义理解而有些提问表述确实模糊模型无法决定其归属。这时模型倾向于往“更像一个问题”的方向猜测。解决方法在意图识别节点里增加“置信度阈值”机制。如果模型输出的分数低于阈值统一归入unknown走拒答分支。不要给模型“猜”的机会。5.4 State字段被覆盖丢失症状上一个节点的输出在下一个节点里突然变成空值。原因通常是两个节点更新了同一个State字段或者Reducer行为不符合预期。排查方法做一个print_state_changes装饰器每次节点调用后打印节点名、更新前后State的diff。规避方法节点代码里不要直接修改入参State始终返回一个新字典作为更新。产生新列表时也用一个新变量避免对上一个节点的产物进行修改。5.5 增加日志和追踪机制辅助排查Agent排查和普通程序排查不太一样核心难点在于错误通常不是单一节点的问题而是多个节点组合之后的结果。我的习惯是所有节点函数都加一个trace装饰器把每一次调用时的输入关键词和输出摘要记录到文件里方便事后回放import functools import logging def trace_node(fn): functools.wraps(fn) def wrapper(state: AgentState) - dict: logging.info([%s] enter: intent%s question%s, fn.__name__, state.get(intent), state.get(current_question)) result fn(state) logging.info([%s] exit: %s, fn.__name__, {k: v for k, v in result.items()}) return result return wrapper有了trace日志大多数“诡异问题”都能在几分钟内定位到是哪个节点引入的错误状态。强烈建议在做任何优化前先把这套日志机制搭好它省下的时间远超你搭建它的成本。6. 写在最后的几点体会文本实操项目做到后期我对LangGraph最大的体会是它不会让LLM更聪明但会让LLM的聪明被圈在正确的范围里。大部分人觉得“可控Agent”意味着限制模型其实本质上是在为它修一条有护栏的道路让它跑得更稳定、更远。这也是我在多轮迭代之后最终选定它作为Agent基础设施的直接原因。如果你正在规划自己的第一个Agent项目我建议不要一开始就做“全自动”。第一步把流程图画出来哪一步需要理解自然语言、哪一步必须引用外部证据、哪一步需要人确认。第二步在每一个“模型输出将被后续逻辑使用”的位置加入校验节点或校验逻辑。第三步再把校验通过的输出放行到下一步。这套方法论本身不依赖LangGraph但LangGraph让它的落地变得非常顺手。最后再分享一个实操小技巧先不给Agent配复杂工具链先用一个最简单的知识检索功能把图跑通把意图识别、检索、生成、校验、拒答这五条分支调稳再逐步加新工具和新节点。我见过不少失败的Agent项目复盘下来都不是死在模型能力上而是死在流程太复杂、错误没有在源头被拦下。一步一步来前面花的时间会在后面加倍赚回来。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑