news 2026/10/14 11:01:04

LangGraph工具调用与菜单RAG:语音助手菜单查询落地实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LangGraph工具调用与菜单RAG:语音助手菜单查询落地实践

语音助手的对话流里,用户说“帮我查一下附近有哪些川菜馆”,系统到底是怎么把“川菜馆”这个模糊的口语表达,变成一次数据库查询,再把查询结果组织成一句像样的人话返回去的?前面几篇我们聊了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_size=400, chunk_overlap=80, 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, k=k * 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 从语音输入到最终回复的完整链路

完整的语音助手流程,跑起来是这样的:

  1. 语音识别模块输出文本;
  2. 文本进入LangGraph的assistant节点,LLM判断要不要调用menu_search;
  3. 如果调用,ToolNode执行RAG检索,返回TopK文档片段和额外元数据;
  4. LLM拿到这些文档片段,整理成一段自然语言回复;
  5. 回复文本交给TTS模块合成语音。

其中第2步、第3步、第4步,全部在LangGraph的图里自动完成。我用的LLM是支持工具调用的模型,上下文长度大概是8k到32k,对于菜单检索这种场景完全够用。

4.2 TopK的设置与重排的必要性

菜单RAG的TopK我最终定为5。太少了——比如K=1——一旦第一个检索结果跑偏,整个回复就错了;太多了——比如K=10——虽然上下文信息量大,但LLM在组织语言时容易把不相关的菜品也带进来,反而让用户困惑。

TopK=5,再配合一个轻量级重排器,我用的是一种基于交叉编码器的模型,它把“用户问题+候选文档”拼接后打一个相关性分数。重排之后取前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_name="BAAI/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还尴尬。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 11:01:03

PHP项目自动化部署:GitLab与Arbess CI/CD实战

1. 项目概述与核心价值在当今快节奏的软件开发环境中,自动化部署已成为提升团队效率的关键。作为一名长期奋战在一线的PHP开发者,我深刻体会到手动部署带来的种种痛点:耗时、易错、难以回滚。Arbess作为一款轻量级CI/CD工具,配合G…

作者头像 李华
网站建设 2026/10/14 11:00:16

ZimaBlue:视频预训练与Slow-Fast架构如何演化通用世界-动作模型

1. 从标题拆解ZimaBlue到底想解决什么问题第一次看到“ZimaBlue:通过可扩展视频预训练,演化通用世界-动作模型”这个标题,我脑子里蹦出来的第一个念头是:又有人想在“世界模型”和“动作模型”之间搭桥了。这个方向这两年热度一直…

作者头像 李华
网站建设 2026/10/14 10:59:35

tkinter表格实战:Treeview滚动条与表头排序完整方案

最近做桌面工具的时候,需要在tkinter界面里展示一批结构化的数据。刚开始想偷懒,直接用Label一行行排,结果数据一多界面直接卡成PPT,滚动还得自己写事件。后来老老实实用了ttk.Treeview,配合Scrollbar和表头排序&#…

作者头像 李华
网站建设 2026/10/14 10:59:27

Apollo-11 高可靠性系统设计:从冗余架构到过载保护

1. 从代号到工程奇迹:Apollo-11 项目的整体设计逻辑第一次听到“Apollo-11”这个代号,很多人脑子里蹦出来的可能是那个著名的登月任务。但在我所从事的软件工程与系统架构圈子里,Apollo-11 更像是一个精神符号——它代表着一类极度苛刻、容错…

作者头像 李华
网站建设 2026/10/14 10:59:07

基于2023数模国赛优秀论文的定日镜场输出功率优化与光学效率计算复现

简介:这份资源是2023年全国大学生数学建模竞赛A题优秀论文的完整PDF,面向备战数模国赛的本科生、研究生及指导教师,聚焦定日镜场输出热功率优化这一光热发电核心问题。论文围绕三个递进问题展开:先建立镜场与镜面坐标系&#xff0…

作者头像 李华
网站建设 2026/10/14 10:58:50

MCP协议实战:让大模型调用文件、数据库和外部服务的完整指南

这几年我把不少杂七杂八的自动化脚本、数据查询工具和外部服务陆续接进了自己的智能助手,折腾了一圈之后,最让我觉得“对了路”的,还是基于 MCP 协议来打通整个工具生态。MCP 全称是 Model Context Protocol,也就是模型上下文协议…

作者头像 李华