语音助手的对话流里用户说“帮我查一下附近有哪些川菜馆”系统到底是怎么把“川菜馆”这个模糊的口语表达变成一次数据库查询再把查询结果组织成一句像样的人话返回去的前面几篇我们聊了LangGraph的状态管理和多轮对话这一篇把最后一块拼图补齐工具调用和菜单RAG。我之前做这个语音助手项目的时候卡在最久的地方不是语音识别也不是大模型对话而是“菜单数据”这一层。菜单不是普通的文本它有价格、辣度、食材、份量、分类还有营业时间、是否售罄这些动态属性。如果直接塞进Prompt里让大模型硬记token爆炸不说菜单一更新就得重新调整Prompt维护成本很高。后来我把菜单数据抽出来做成RAG再把RAG包装成LangGraph里的一个工具节点整个流程一下就顺了。这篇博客把我在实操里踩过的坑和最终落地的方案完整写出来涉及的代码、参数、配置都是可以直接复制到项目里改的。不管是准备把RAG接进语音助手的还是想研究LangGraph工具调用机制的这篇应该都能帮上忙。1. 整体设计思路为什么要把菜单拆出去1.1 先看一段实际对话理解系统在做什么用户对着手机说“你好我想点一份麻辣香锅不要香菜微辣二十分钟能送到吗”这句话如果交给传统规则系统去解析基本很难精确命中因为里面有四个独立的意图点——选品麻辣香锅、忌口不要香菜、口味约束微辣、时间约束二十分钟内送达。语音助手必须同时处理这四项约束还得查一下菜品库里到底有没有麻辣香锅、今天是否售罄、预计多久能出餐。更麻烦的是用户问话的方式千变万化。同一道菜有人叫“麻辣香锅”有人叫“香锅”有人叫“麻辣干锅”。菜单RAG的价值就在这里它不是让大模型凭空记住菜品而是先把菜单文本向量化建立索引来了问题先检索出最相关的菜单片段再把片段和用户的问题一起交给大模型去整理和判断。1.2 工具调用的职责边界谁该干什么必须分清楚我设计的这个语音助手分工是这样的语音识别模块负责把声音转成文字它不关心业务逻辑LLM主对话负责理解用户意图但不会直接编造菜品的售价或库存菜单RAG负责从向量库里检索候选菜品信息是一个“工具”LangGraph的ToolNode负责把RAG的检索结果交还给LLM让LLM决定怎么组织回复。这个职责划分我在早期版本里做得不好。最初我把RAG检索到的原始文本直接拼进Prompt结果大模型经常把碎片信息当成完整事实来用比如它可能只看到了菜品简介里的一行字“口味偏辣”就回复用户“这个菜很辣”完全没有意识到这个结论只是一篇菜谱描述里的片面信息。工具调用的接入让LLM有了一个标准的“查证”流程先检索再判断最后组织语言。1.3 LangGraph在这里扮演什么角色LangGraph在这个项目里主要管两件事状态流转和条件分支。比如用户说“帮我点一份黄焖鸡米饭”系统需要判断这个问题需不需要走RAG如果需要走RAG之后结果是命中还是没命中如果命中是不是要把结果继续往下游的订单模块传这些判断在代码里是一个个条件边LangGraph把它们组织成一个可追踪的图。调试语音助手的时候我可以在每一步看一眼状态里的关键字段——用户在问什么、检索到了什么、LLM回了什么、下一步走哪个节点。这个能力在排障的时候太重要了后面我会专门讲一次现场排查过程。2. 菜单RAG的落地细节从清洗到检索2.1 菜单数据的结构化拆分别一上来就调embedding接口先把手里的菜单数据整理好。我拿到的原始数据大概长这样麻辣香锅原料藕片、土豆片、午餐肉、培根、包菜口味可选择微辣、中辣、特辣配米饭一碗出餐时间约12分钟售价38元。这种一段式的描述很难做精准检索。我把它拆成了结构化字段菜名含别名食材清单口味标签价格出餐时间分类荤/素/主食/汤品拆完之后每个菜品生成一个统一格式的文档块比如【菜品名称】麻辣香锅 【别名】香锅、麻辣干锅 【分类】荤菜·干锅类 【食材】藕片、土豆片、午餐肉、培根、包菜 【口味选项】微辣、中辣、特辣 【默认辣度】中辣 【出餐时间】约12分钟 【价格】38元 【注意】默认含香菜可备注去除为什么这样拆因为RAG的检索是“语义匹配”但用户的问题往往是复合的——他问的是“有没有不辣的干锅”而不是单纯搜“干锅”两个字。结构化之后每一个字段都能参与向量匹配检索结果会明显更准。实测下来改成这种格式之后检索Top5的相关性评分大概提升了20多个百分点。2.2 文本切分与向量化参数文本切分的思路很简单一个菜品的所有描述合起来做一个chunk不要让切分器把一个菜品劈成两半。我之前用过通用的按字符切分后果就是检索“宫保鸡丁”的时候返回的前两条结果里有一条是“辣子鸡丁”的食材清单因为它们在同一个chunk里挨得太近。实际操作时我配置的是from langchain.text_splitter import RecursiveCharacterTextSplitter # 每个菜品独立成一个chunk不再额外跨菜品切分 text_splitter RecursiveCharacterTextSplitter( chunk_size400, chunk_overlap80, separators[\n\n, \n, 。, ], )chunk_overlap设置成80是为了保证相邻菜品如果被意外分到同一个chunk至少保留一些上下文连贯性。我的实际数据里一道菜平均是200到300字左右所以400的chunk_size基本能塞下完整的一道菜。如果你的菜单比较复杂例如有大量“菜品做法的长描述”那建议chunk_size调到600但别超过800太大之后检索精度会往下掉。embedding模型我用的是一般的通用中文向量模型。选型上有个建议不建议用特别大的模型来做菜单检索毕竟语音助手对响应延迟很敏感。我自己测试过大模型做embedding效果确实好一点但每次检索多了几百毫秒延迟对话体感明显变差后来换成了轻量模型延迟降下来准确率只差了一两个点。2.3 检索的混合策略向量检索 关键词兜底向量检索不是万能的它最大的弱点是处理“冷门词”和“精确词”不够稳。举一个实际例子菜单里有一道“清炒时蔬”但用户说的是“来个绿菜”。向量检索能把“绿菜”和“清炒时蔬”关联起来吗不一定因为“绿菜”在语料里没有出现过embedding模型没见过这个词的组合匹配分就会很低。我给检索模块加了一个关键词兜底先用向量检索取Top20同时用一个轻量级关键词表菜名、别名、分类名做精确匹配两个结果合并去重再统一送去重排重排。这个策略在实际测试里帮了大忙至少把命中率提升了15%。关键词表不用手工维护太多就从结构化数据里自动抽取每隔几天重新生成一次就行。from langchain_community.vectorstores import FAISS from langchain_core.documents import Document # 构建关键词倒排索引 keyword_index {} for doc in menu_docs: for kw in [doc.metadata.get(name), doc.metadata.get(aliases, )]: keyword_index.setdefault(kw, []).append(doc) # 向量检索 关键词兜底 def hybrid_search(query: str, k: int 5): vec_results vector_store.similarity_search_with_score(query, kk * 2) kw_hits [] for kw, docs in keyword_index.items(): if kw in query or query in kw: kw_hits.extend(docs) merged dedupe([doc for doc, _ in vec_results] kw_hits) return merged[:k]这套混合检索实现了之后菜单RAG才真正在一个语音助手的场景里站住了脚。3. 把RAG封装成LangGraph工具ToolNode的接入方式3.1 工具定义与描述技巧LangGraph的工具调用机制本质上就是让LLM在对话中主动请求去调用一个外部函数。这个外部函数可以是查天气、查数据库、查菜单RAG也可以是任何东西。关键是要让LLM在合适的时机调用对的工具怎么让LLM“知道”该用哪个工具靠的是工具的描述。我最初写工具描述时写得很简陋menu_tool create_retriever_tool( retriever, menu_search, 搜索菜单信息。, )结果LLM经常在用户随便聊两句的时候就触发工具调用比如用户问“你们几点关门”LLM也去搜菜单显然浪费算力。后来我把描述改成了menu_tool create_retriever_tool( retriever, menu_search, 当用户询问菜品、口味、价格、食材、辣度、出餐时间等与菜单相关的问题时 使用此工具搜索菜单知识库。无需搜索天气、营业时间、地址等非菜单信息。, )描述改详细之后工具触发的准确率立刻上来了。这里有一条通用经验工具的描述是在给LLM画边界——明确告诉它什么时候用什么时候不用。描述含糊LLM就会乱调用。3.2 LangGraph的图结构配置我的LangGraph流程设计成了一个有环的图。为什么要有环因为LLM可能第一次调用工具之后拿到了检索结果还觉得不够需要再调用一次确认细节。举个例子用户问“回锅肉现在还有吗”第一次检索返回了菜品口碑和口味的描述信息但库存状态字段不在检索结果里LLM就会主动发起第二次工具调用查询具体门店的库存状态表。这个“再调一次”的能力就需要图结构里允许从工具节点回流到LLM节点。from langgraph.graph import StateGraph, END from langgraph.prebuilt import ToolNode, tools_condition # 定义状态结构 class MenuState(TypedDict): messages: list menu_context: list # 构建图 graph StateGraph(MenuState) # LLM节点负责理解和决策需要绑定工具 graph.add_node(assistant, call_assistant) graph.add_node(menu_tools, ToolNode([menu_tool])) # 入口边 graph.set_entry_point(assistant) # 条件边如果LLM决定调用工具就进入 tool 节点否则直接结束 graph.add_conditional_edges( assistant, tools_condition, {tools: menu_tools, END: END}, ) # 工具执行完成后回流到 assistant让LLM结合工具结果生成最终回复 graph.add_edge(menu_tools, assistant) app graph.compile()这个结构里值得注意的地方tools_condition是预编译的条件函数它会检查最后一条消息里是否有tool_calls如果有就走menu_tools没有就结束。ToolNode会自动执行LLM请求的工具并把结果包装成ToolMessage回填到消息列表里。3.3 LLM选择工具的内部机制其实LLM“决定是否调用工具”的过程可以理解成一次概率预测。模型会根据当前对话历史里的最近几条消息结合工具的描述文本计算哪一个动作直接回答或调工具更合适。我调试的时候发现一个规律如果对话历史里有很多轮关于“天气”的内容LLM更容易误触发菜单工具因为“工具描述”里的“查询”两个字给了模型一个模糊的指引。所以让工具描述里的关键词和实际查询域强对齐并且弱化通用动词是有帮助的。还有一个经验语言模型对中文工具描述的理解能力普遍弱于英文。如果条件允许给工具描述同时加一段英文示例menu_tool create_retriever_tool( retriever, menu_search, Search for dish info like price, ingredients, spice level. 中文描述当用户询问与菜单相关的问题时使用此工具查询菜单知识库。, )实测这种中英混合的描述触发的稳定性更高。4. 实操中的完整流程与参数选择4.1 从语音输入到最终回复的完整链路完整的语音助手流程跑起来是这样的语音识别模块输出文本文本进入LangGraph的assistant节点LLM判断要不要调用menu_search如果调用ToolNode执行RAG检索返回TopK文档片段和额外元数据LLM拿到这些文档片段整理成一段自然语言回复回复文本交给TTS模块合成语音。其中第2步、第3步、第4步全部在LangGraph的图里自动完成。我用的LLM是支持工具调用的模型上下文长度大概是8k到32k对于菜单检索这种场景完全够用。4.2 TopK的设置与重排的必要性菜单RAG的TopK我最终定为5。太少了——比如K1——一旦第一个检索结果跑偏整个回复就错了太多了——比如K10——虽然上下文信息量大但LLM在组织语言时容易把不相关的菜品也带进来反而让用户困惑。TopK5再配合一个轻量级重排器我用的是一种基于交叉编码器的模型它把“用户问题候选文档”拼接后打一个相关性分数。重排之后取前3个直接进入Prompt。这个“粗排Top5精排Top3”的两级筛选方案是我在实际项目里反复调出来的平衡点既保证召回率又不让LLM上下文被无用信息塞满。4.3 状态的传递与维护菜单RAG检索出来的结果不是一次性用完就丢了它要在后续多轮对话里发挥作用。举例用户先问“有哪些辣一点的菜”系统返回了水煮鱼和毛血旺用户接着说“第二个多少钱”这时候系统得记得“第二个”指的是毛血旺。如果没有状态管理后面这句话单独拿出来就是一句无法理解的话。所以我在LangGraph状态里专门维护了menu_context字段把上一轮检索出的Top菜品列表存下来。后续LLM生成回复时除了携带前几轮对话消息还会额外携带menu_context作为参考上下文。class MenuState(TypedDict): messages: Annotated[list, operator.add] menu_context: list # 上一轮菜单检索的上下文快照这就是LangGraph的一个核心优势它本身就是一个天然的状态管理器。用自定义状态对象把所有需要的信息串起来每一步都对状态做增量更新多轮对话的自然度会高很多。5. 踩过的坑与排查实录5.1 菜单检索结果为空第一次上线测试时我一个同事用很重的方言说了一个菜名语音识别转出来是“饣开锅肉”音近“回锅肉”RAG里面完全找不到。当时查出来的问题是语音识别文本里混入了语气词和错字向量检索没办法对错字做容错。解决方案是在LLM进入工具调用之前加了一个“查询改写”步骤让LLM先把带口音的文本转成标准菜名再去做RAG检索。这一点非常有效。如果你的语音助手识别准确率不算高强烈建议在RAG之前加这么一层。5.2 ToolNode循环调用导致超时有一版代码里LLM不停地调用工具形成了“assistant → menu_tools → assistant → menu_tools”的死循环尤其是当检索结果没有直接回答用户问题时LLM会认为是自己的工具参数没传对反复尝试调用。排查方法是加日志打印出每一步的消息类型。最后我在条件边这里加了一个调用次数上限MAX_TOOL_CALLS 3 def should_continue(state): if len(state[messages]) MAX_TOOL_CALLS * 2: return END return menu_tools if state[messages][-1].tool_calls else END超过3次后强制结束然后让LLM基于已有的检索结果做一次总结性回答。这个有上限的设计是为了防止工具调用失控。实测下来正常对话基本1到2次调用就能完成所以这个上限设成3是合理的。5.3 用户问“菜单”两个字时LLM不知道搜什么这个坑挺经典的。用户问“你家菜单有什么”这句话没有具体的菜品名向量检索出来的结果会很散没有一个明确的中心。我的处理方式是维护一份“招牌菜”列表每当用户问“有什么推荐”“菜单发我”这类宽泛问题时直接返回招牌菜列表不走RAG。这一步判断放在LLM节点里通过一个is_broad_query的开关来控制路由。结果对用户更加友好而不是让用户面对一堆五花八门的检索碎片。5.4 Embedding模型的维度和归一化细节向量检索里有个容易被忽略的细节如果不做向量归一化不同菜品文档的长度差异会直接影响相似度计算。长文档天然比短文档更容易被检索到这在菜单RAG里是致命的——因为很多高价值信息恰恰在那些简短的描述里。我用的FAISS索引存储时对向量做了归一化处理检索时计算的是余弦相似度。这么做之后长文档的“长度优势”被抹平短文档里的“微辣”“无香菜”这类精确标签也能被正常检索出来。from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import FAISS embeddings HuggingFaceEmbeddings( model_nameBAAI/bge-small-zh-v1.5, encode_kwargs{normalize_embeddings: True}, ) vector_store FAISS.from_documents(docs, embeddings)顺便说一句中文场景下用中文专门训练的向量模型效果可以参考bge系列或m3e系列实测在菜单语义匹配上要比多语言通用模型好不少。6. 应用场景扩展这套架构还能接什么工具调用和RAG的组合在语音助手里不只是能查菜单。同样的模式接上闹钟、日程、购物清单立刻就是一个完整的智能管家框架。我试过一个扩展把RAG检索的对象从菜单换成门店FAQ文档工具从menu_search改成faq_search流程几乎不用变就能让语音助手回答各种非菜单类问题。这说明这套架构的通用性很强。再往后走可以叠加多个工具节点让LLM根据用户意图自主选择调用不同工具比如用户说“明天北京天气怎么样顺便提醒我带伞”语音助手就可以同时唤起天气查询和日程提醒两个工具。另外菜单RAG本身还可以做一个重要扩展接入实时库存系统。目前我手里的菜单知识库是离线构建的库存状态一天更新两次。如果要做到实时可以在工具调用时先查RAG里的菜品基础信息再根据菜名去库存服务动态查询现实库存。这个组合让LLM的回答兼具知识性菜品描述和实时性当前状态语音助手的可用性会明显上一个台阶。个人操作习惯上我会做每次修改菜单数据之后运行一个脚本重新生成向量索引并把索引文件版本号和菜单数据版本号对齐。如果版本不一致RAG检索出来的内容和实际菜单对不上就会出现推荐了已下架菜品的情况这种事故的用户体感非常差。如果后续要把这套方案做得更稳我建议顺着这几个方向深挖引入用户偏好记忆让RAG检索时带上用户的历史点单记录加入权限控制让工具节点可以区分不同用户的访问范围再就是整个图加可观测性监控统计工具的触发率、检索命中率和平均响应耗时出了问题第一时间能定位到具体环节。这个系列走到这里语音助手的技术栈算基本齐了。工具调用和菜单RAG看着是两个独立模块真正跑起来之后会发现它们把“理解意图”和“提供知识”彻底打通了——前者决定语音助手该做什么后者决定它说的话靠不靠谱。如果你也在做同类项目建议先小范围试水把RAG的检索质量调过关了再接工具调用否则LLM把糟糕的检索结果当事实说出来比不做RAG还尴尬。