尧图精选

Haystack + LangGraph 生产级 RAG 实战:工具合约与上下文工程

🕒 发布时间:2026/10/2 12:03:32 📁 来源:尧图网络
1. 从能跑通到敢上线RAG 项目真正的分水岭在哪很多人第一次搭 RAG流程都差不多文档切块、向量化、塞进向量库、检索、拼 prompt、丢给 LLM。本地跑个 demo问几个问题答案看着还挺像那么回事于是就觉得RAG 不过如此。可真到了要上生产、要接真实用户、要处理几千上万份文档的时候问题就全冒出来了——检索召回忽高忽低、多轮对话上下文丢失、工具调用参数对不上、模型偶尔胡编乱造、成本还压不下来。我自己踩过最典型的一个坑早期用最朴素的固定长度切块 单一向量检索做知识库测试集上命中率看着有 80%上线后用户投诉不断。后来复盘才发现那 80% 是检索到了相关段落但真正能回答对问题的比例只有一半出头。检索命中不等于回答正确这是 RAG 里最容易被忽略的认知差。这篇要聊的就是怎么用Haystack和LangGraph这两个框架把 RAG 从玩具做成生产级。核心围绕三件事展开RAG 流水线的工程化、工具合约Tool Contract的设计、以及上下文工程Context Engineering。关键词里的 Haystack、LangGraph、RAG、LLM、上下文工程会贯穿全文。适合已经跑通过基础 RAG、想往生产环境推进的开发者也适合正在选型、纠结用哪个框架的朋友。先说结论性的判断Haystack 负责数据与检索这条确定性流水线LangGraph 负责决策与编排这条带状态的智能体流水线。两者不是竞争关系而是分工关系。理解这一点后面所有的设计都会顺。2. 为什么是 Haystack LangGraph 这套组合而不是二选一2.1 两个框架的定位差异决定了它们天然互补Haystack 的强项在于组件化的检索流水线。它把 Document Store、Retriever、Ranker、Reader、Generator 这些环节拆成标准组件用 Pipeline 串起来每个组件可以单独替换、单独测试。这种设计对确定性流程极其友好——给定一个 query走哪几步、每步输出什么都是可预测、可断点调试的。LangGraph 的强项在于带状态的有向图编排。它把 LLM 应用建模成一张图节点是操作边是流转条件还能维护跨轮次的 State。这对需要根据中间结果动态决策的场景是刚需——比如先判断用户意图再决定是走检索、走工具调用、还是直接闲聊检索结果不够好时要不要改写 query 重试。我见过不少人硬要用一个框架干所有事。用 LangGraph 硬写检索流水线结果把简单的 ETL 搞得无比复杂或者用 Haystack 硬做多轮决策结果状态管理写得一团乱。工具选型的第一原则是让确定性的部分保持确定性让需要决策的部分才引入智能体。2.2 一张表看清职责边界维度Haystack 负责LangGraph 负责核心抽象Pipeline ComponentGraph Node State典型任务文档清洗、切块、嵌入、检索、重排意图路由、多轮对话、工具编排、重试状态管理无状态为主单次请求内流转显式 State跨节点、跨轮次持久化调试方式逐组件打印中间输出逐节点追踪、可视化图执行路径适合场景数据侧、检索侧决策侧、交互侧这张表不是绝对的但能帮你快速判断这段逻辑该放哪边。我的经验是凡是能用固定步骤描述清楚的放 Haystack凡是需要看情况的放 LangGraph。2.3 组合后的整体架构长什么样一个生产级 RAG 系统我通常拆成三层数据层文档接入、解析、清洗、切块、嵌入、入库。这层用 Haystack 的 indexing pipeline离线跑可重跑。检索层query 改写、多路召回、重排、上下文组装。这层用 Haystack 的 query pipeline在线跑低延迟。编排层意图识别、工具选择、多轮状态、失败重试、结果校验。这层用 LangGraph把检索层当成一个工具节点来调用。这样分层的好处是检索层可以独立做评测和优化编排层可以独立做逻辑迭代两边互不干扰。上线后要调检索召回不用动编排代码要加新工具不用碰检索流水线。3. 用 Haystack 搭一条经得起压测的检索流水线3.1 切块策略别再用固定长度了固定长度切块比如每 512 token 一刀切是最省事、也是最容易埋雷的做法。它会把一个完整的语义单元拦腰截断导致检索到的片段半句话LLM 拿到残缺上下文自然答不准。我在实际项目里更推荐语义感知切块具体做法是优先按文档结构切标题、段落、列表项保留层级信息段落过长时按句子边界切而不是按字符数切每个 chunk 保留一定的重叠overlap通常 10%~20%防止边界信息丢失给每个 chunk 附上元数据来源文档、章节路径、页码、时间戳。元数据这块特别重要后面做过滤检索、做引用溯源、做权限控制全靠它。我见过太多项目一开始不存元数据后期想加只检索某个部门文档的功能只能全量重跑嵌入成本极高。3.2 多路召回单一向量检索的天花板很低纯向量检索擅长语义相似但对精确匹配比如产品型号、专有名词、数字很弱。生产环境里用户的问题往往两者混杂。我的做法是混合检索向量召回负责语义相近的内容关键词召回BM25负责精确词命中两路结果融合用 RRFReciprocal Rank Fusion或加权分数合并。RRF 的好处是不需要归一化不同检索器的分数直接按排名融合工程上很省心。实测下来混合检索相比纯向量在含专有名词的 query 上召回提升非常明显。3.3 重排Rerank把相关变成最相关召回阶段追求的是不漏通常会取 top 20~50但塞给 LLM 的上下文有限必须精选。这时候就需要重排模型对召回结果做精细打分取 top 3~5。重排模型cross-encoder 类比向量检索慢但精度高得多。我的经验是召回用便宜快速的重排用精准但慢的各司其职。如果延迟敏感可以把重排放在异步或缓存层。3.4 上下文组装给 LLM 的信息要刚刚好检索到 5 个 chunk不是简单拼接就完事。我通常按这个顺序组织系统指令角色、约束、输出格式检索到的上下文每个 chunk 标注来源编号用户问题输出格式要求比如引用来源时用 [1][2]。这里有个反直觉的点上下文不是越多越好。塞太多无关内容反而会稀释关键信息还会推高 token 成本。我一般控制在 2000~4000 token 的上下文区间具体看模型窗口和任务复杂度。提示上下文里每个 chunk 都带上来源编号让 LLM 在回答时引用既方便用户核查也方便你事后做归因分析——哪个 chunk 被引用了、哪个从没被用过一目了然。4. 工具合约让 LLM 调用工具不再靠猜4.1 什么是工具合约为什么它比 prompt 更重要工具合约Tool Contract指的是对每个可被 LLM 调用的工具明确定义它的名称、用途、参数结构、返回结构、以及失败时的行为。很多人写工具调用就在 prompt 里写一句你可以调用 search 工具然后祈祷模型参数填对。这在 demo 里能跑在生产里必崩。合约的核心价值是把模型自由发挥变成模型在约束内选择。参数类型、必填项、取值范围都定义清楚模型填错的概率会大幅下降返回结构固定下游解析才不会崩。4.2 参数设计三个关键点我在设计工具参数时会反复问三个问题这个参数模型能可靠地填出来吗如果参数需要复杂推理才能得出不如让模型先输出中间结果再由代码转换。参数有没有默认值有默认值的参数设为可选减少模型负担。参数要不要做枚举约束比如时间范围只允许day/week/month用枚举比让模型自由填字符串可靠得多。举个具体例子一个检索工具的参数我会这样设计{ name: search_knowledge_base, description: 在内部知识库中检索相关文档片段用于回答事实性问题, parameters: { type: object, properties: { query: { type: string, description: 检索用的自然语言查询应聚焦单一主题 }, top_k: { type: integer, description: 返回的文档片段数量, default: 5, minimum: 1, maximum: 20 }, filter_dept: { type: string, description: 限定检索的部门范围, enum: [tech, hr, finance, all], default: all } }, required: [query] } }注意description的写法——它不是给用户看的是给模型看的。要写清楚什么时候该用这个工具而不是这个工具是什么。模型判断要不要调用靠的就是这段描述。4.3 返回结构固定 schema 是稳定性的前提工具返回给模型的内容我坚持用结构化 JSON而不是一段自由文本。原因很简单结构化返回可以被代码校验、被日志记录、被下游程序消费自由文本只能靠模型自己理解出错无从排查。一个检索工具的返回我会这样设计{ status: success, results: [ { id: doc_123_chunk_4, content: ……, source: 员工手册_v2.pdf, score: 0.87 } ], total_found: 12, truncated: true }status字段让模型知道这次调用成没成功truncated告诉模型结果被截断了可能需要缩小范围重试。这些细节都是让智能体知道自己处境的关键。4.4 失败处理工具报错时模型该怎么办工具调用失败是常态——网络超时、参数非法、无结果返回。关键是让模型知道失败了并且知道怎么补救。我的做法是失败时返回status: error加error_type和message在系统 prompt 里明确告诉模型遇到error_type: no_results时应该改写 query 重试遇到error_type: invalid_params时应该检查参数后重试。这样模型就有了自我修复的依据而不是一失败就摆烂或者胡编。5. 上下文工程决定 RAG 上限的隐形战场5.1 上下文工程到底在工程什么上下文工程Context Engineering这个词最近很热但很多人理解得比较窄以为就是把检索结果拼进 prompt。实际上它涵盖的范围大得多选什么进上下文哪些信息该给模型哪些不该给以什么形式进上下文结构化还是自然语言带不带来源标注什么时候进上下文一次性给全还是分步给上下文怎么随对话演进多轮对话里历史信息怎么压缩、怎么取舍。我把它理解成给模型设计一个恰到好处的信息环境。信息太少模型答不准信息太多模型抓不住重点还费钱信息给错模型直接被带偏。5.2 多轮对话里的上下文压缩多轮对话最头疼的是历史越滚越长。全量保留token 成本爆炸粗暴截断早期关键信息丢失。我的做法是分层处理最近 N 轮完整保留保证对话连贯更早的历史用 LLM 做摘要压缩保留关键事实和结论检索到的文档每轮独立检索不跨轮累积避免上下文污染。这里有个细节摘要压缩要保留实体和数字比如用户提到预算 50 万这种信息压缩时不能丢。我一般会在摘要 prompt 里明确要求保留所有具体数字、名称、时间。5.3 用 LangGraph 的 State 管理上下文流转LangGraph 的 State 是上下文工程的好帮手。我把 State 设计成包含这几块messages对话历史retrieved_docs当前轮检索到的文档tool_calls本轮工具调用记录summary历史摘要user_profile用户偏好、权限等长期信息。每个节点读写 State 的特定字段职责清晰。比如检索节点只写retrieved_docs生成节点只读retrieved_docs和messages。这种读写分离让调试变得非常容易——出问题时打印 State 就知道哪一步出了岔子。5.4 上下文里的噪音怎么清检索难免带回无关内容。我的清理策略有三条重排后设阈值分数低于阈值的直接丢弃宁缺毋滥去重多个 chunk 内容高度重叠时只保留信息量最大的冲突检测如果两个 chunk 对同一事实说法矛盾要么都保留让模型判断要么标记出来提示模型注意。第三条尤其重要。知识库更新不及时时新旧文档冲突很常见。与其让模型悄悄选一个不如显式提示存在冲突信息让模型在回答里说明。6. 把检索层接进 LangGraph一个可复现的编排骨架6.1 图结构设计从意图到回答的完整链路我常用的图结构是这样的入口节点接收用户输入初始化 State意图路由节点判断是知识问答、工具操作还是闲聊检索节点调用 Haystack 检索流水线工具节点执行具体工具调用生成节点组装上下文调用 LLM 生成回答校验节点检查回答是否有引用、是否偏离问题出口节点返回结果更新 State。条件边负责在节点间流转比如意图路由后根据结果走不同分支校验不通过时回到生成节点重试。6.2 意图路由别让所有问题都走检索一个常见误区是所有问题都先检索一遍。实际上很多问题比如你好、帮我算个数根本不需要检索硬走一遍既慢又浪费。意图路由节点就是干这个的——用一次轻量 LLM 调用或小模型做分类把问题分流。分类的类别我一般设这几类knowledge_query走检索、tool_action走工具、chitchat直接生成、clarify需要反问用户。分类 prompt 要写得具体给出每类的判断标准和例子。6.3 检索节点的实现要点检索节点本质上是把 LangGraph 的 State 翻译成 Haystack 的 query再把结果翻译回 State。要点有三个query 改写用户口语化的问题先改写成适合检索的形式。比如那个报销的事咋弄改成报销流程 报销标准。多路召回 重排前面讲过的混合检索在这里落地。结果封装把 Haystack 返回的 Document 对象转成 State 里的结构化格式带上来源、分数、元数据。6.4 生成节点的 prompt 模板生成节点的 prompt 我通常这样组织你是企业内部知识助手。请基于以下检索到的资料回答用户问题。 规则 1. 只使用资料中的信息回答不要编造。 2. 如果资料不足以回答明确说明资料中未找到相关信息。 3. 引用资料时用 [编号] 标注来源。 4. 回答要简洁直接给结论再给依据。 资料 [1] {chunk_1_content}来源{source_1} [2] {chunk_2_content}来源{source_2} 用户问题{user_question}这个模板的关键是规则前置、资料居中、问题后置。规则放最前面模型注意力最集中问题放最后紧挨着生成位置符合模型的就近原则。6.5 校验节点给回答加一道保险校验节点做两件事引用检查和相关性检查。引用检查看回答里有没有[编号]没有的话可能是模型在编相关性检查用一次轻量 LLM 调用判断回答是否真的回应了问题。任一不通过就回到生成节点重试最多重试 2 次避免死循环。这道保险在早期能挡掉不少低级错误。上线后你会发现用户对答非所问的容忍度极低宁可回答没找到也不要答一堆无关内容。7. 上线前必须做的几件事评测、监控与成本控制7.1 检索评测别凭感觉判断好坏检索质量必须量化。我通常准备一个评测集50~200 条真实问题每条标注应该命中的文档 ID。然后跑检索算两个指标RecallKtop K 里有没有命中正确文档MRR正确文档排在第几位。这两个指标能客观反映检索好坏。我见过团队凭感觉调参改了半天其实没提升有了评测集每次改动都能看到数字变化。7.2 回答评测LLM as Judge 的正确用法回答质量评测可以用 LLM 当裁判LLM as Judge但要设计好评测维度。我一般评四项准确性有没有事实错误、完整性有没有漏关键点、引用正确性引用是否对应、简洁性有没有废话。每项 1~5 分让裁判模型逐项打分并给理由。要注意的是LLM 裁判本身有偏差比如偏爱长回答。所以我会同时保留人工抽检用人工结果校准裁判模型。7.3 监控指标上线后盯什么上线后我重点盯这几个指标指标含义异常信号检索命中率有结果返回的请求占比突然下降说明索引或 query 改写出问题平均延迟端到端响应时间上升说明某环节变慢工具调用成功率工具正常返回占比下降说明工具或参数设计有问题重试率触发重试的请求占比上升说明生成或校验环节不稳定Token 消耗每请求平均 token上升说明上下文膨胀这些指标配合日志能快速定位问题。我习惯把每次请求的 State 快照存下来出问题时直接回放。7.4 成本控制三个立竿见影的手段缓存相同或相似 query 的检索结果缓存命中直接返回分级模型简单任务用小模型复杂任务才用大模型上下文裁剪严格控制塞进 prompt 的 token 数定期审查有没有冗余。这三条做下来成本通常能降一半以上而且不影响体验。8. 几个我踩过的坑和对应的解法8.1 切块重叠导致的重复引用早期我把 overlap 设得太大30%结果检索时经常召回内容高度重叠的多个 chunkLLM 引用时出现同一句话引用两次。后来把 overlap 降到 15%并在重排后加了去重逻辑问题解决。overlap 不是越大越好够用就行。8.2 工具描述写得太技术模型不会用有次我写了个工具描述是执行向量相似度检索返回 top-k 文档。结果模型很少调用它因为它不知道什么时候该用。改成当用户询问公司政策、流程、产品信息等事实性问题时使用之后调用率立刻上来了。工具描述要写使用场景不是技术实现。8.3 多轮对话里检索结果污染有段时间我发现第二轮对话的回答经常混进第一轮检索的文档。排查后发现是 State 里retrieved_docs没清空跨轮累积了。后来改成每轮检索前先清空该字段问题消失。State 字段的生命周期要明确该清的必须清。8.4 校验节点导致的死循环校验节点刚上线时偶尔出现无限重试。原因是重试时没有改变任何输入模型每次都生成同样的回答校验每次都不过。后来加了重试时降低温度和最多重试 2 次两个约束彻底解决。任何带重试的循环都必须有终止条件。9. 关于这套组合我个人的几点体会Haystack 和 LangGraph 这套组合我用了大半年最大的感受是它逼着你把确定性和不确定性分开思考。Haystack 那边每一步都是确定的可以写测试、可以做评测LangGraph 那边每一步都可能分支需要设计好状态和兜底。这种分离让整个系统的可维护性上了一个台阶。另一个体会是上下文工程的价值被严重低估了。很多人把精力全花在换模型、调参数上却忽略了给模型什么信息才是决定上限的关键。同样的模型上下文组织得好和差回答质量能差出一个档次。最后说个实操建议先跑通最小闭环再逐步加复杂度。别一上来就上多路召回、重排、多智能体先把检索 生成这条主线跑稳有了评测基线再一项项加。每加一项都用评测集验证有没有真的提升。这样迭代方向不会跑偏。这套东西后续还能往几个方向扩展比如接入更细粒度的权限控制让不同用户检索到不同范围的内容比如把工具合约标准化成一套内部规范团队共用比如引入缓存层和异步处理进一步压延迟。这些等主线稳定了再逐个上不急。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →