从LlamaIndex RAG到多智能体Agent:手机客服系统全链路实战
前不久我在一个手机电商客服方向的实验项目里用同一份手机机型数据集把 LlamaIndex RAG 到多智能体 Agent 的完整链路跑了一遍。这个链路分三层第一层是单机器人问答用户问“3000 元拍照好的手机推荐哪款”系统从产品库里检索出参数并给出带依据的回答第二层是品牌专家路由用户提到“极昼”或“云途”时问题会被自动分发给对应品牌的专家引擎第三层是多智能体协作下单用户咨询完说“帮我下单”产品专家、库存专员、下单专员三个 Agent 接力完成订单创建。整个过程用的数据只有一份一百多行的手机产品表。为什么拿手机机型数据而不是某本书或新闻语料来写教程因为手机客服这个场景把 RAG 和 Agent 要面对的问题都凑齐了——既有结构化参数价格、电池、摄像头又有非结构化表达“通勤用够不够”“拍照怎么样”最后还要落到真实业务动作查库存、下订单。把这三层学完你基本就能把同样的套路迁移到家电、理财、租赁等任何垂直客服场景。下面我按从简单到复杂的顺序把每一层的实现思路、代码和踩过的坑完整写出来。1. 为什么拿“手机机型数据集”当 RAG 与 Agent 的练手主线1.1 一个数据集正好覆盖问答、路由、协作三层复杂度很多 RAG 教程喜欢用 PDF 或网页文章做素材但那种数据有一个问题检索到就基本算成功因为答案就藏在片段里。真实客服场景完全不是这样用户不会按教科书提问也不会老老实实只问单个品牌。手机机型数据有一个优点它天然就是“表格里的结构化知识”但它要服务的却是“口语化的非结构化问题”。我用的演示数据集长这样品牌和型号都做了脱敏处理统一换成化名极昼、云途、山岚。品牌型号上市年份价格主摄电池主要特点极昼A15 Pro202459991 英寸大底主摄4800mAh影像旗舰、金属机身云途M10202439995000 万像素主摄5500mAh长续航、商务外观山岚B8202319996400 万像素主摄5000mAh高性价比、游戏优化这个表总共几十行覆盖三个品牌、不同价位段。第一层单机器人问答用整张表灌索引第二层品牌路由按 brand 字段把索引拆成三份让每个品牌有自己的专家引擎第三层下单模型名和价格字段直接变成订单参数的来源。同一份数据三遍使用代码量不大但每一遍都在解决上一遍暴露的新问题。1.2 LlamaIndex 在整套链路里到底扮演什么角色LlamaIndex 不是数据库也不是大模型它是数据和 Agent 之间的一层编排框架。你可以把文档、切分规则、向量索引、查询引擎、工具、Agent 全部定义在一个项目里它负责把这些组件串起来。我最看重的一点是同一批文档既能喂给向量索引做基础问答也能封装成工具给 Agent 调用不需要为不同阶段重写数据管道。需要说明的是LlamaIndex 的 API 版本迭代比较快尤其是 Agent 部分。我下面的代码基于相对新的写法单机器人问答用 VectorStoreIndex 和 query engine多智能体用 AgentWorkflow 和 FunctionAgent。如果你装的是老版本类名和参数名可能不同但核心思路是一致的照着思路换成对应 API 即可。2. 第一层单机器人问答——先把 RAG 的底子打稳2.1 数据建模把产品参数表翻成“人话文档”很多人拿到表格第一反应就是直接把 CSV 灌进索引我试过效果很差。原因很简单Embedding 模型是在自然语言句子上训练的你给它键值对它很难理解“5999 元对应的是哪一款、这款主打什么”。更好的做法是先把每一行手机参数拼成一段读起来像商品描述的文本再作为 Document 交给索引。from llama_index.core import Document from llama_index.core.node_parser import SentenceSplitter def build_documents(df): docs [] for _, row in df.iterrows(): text ( f{row[brand]} {row[model]}{row[year]}年发布 f上市价 {int(row[price])} 元。 f屏幕{row[screen]}处理器{row[cpu]} f主摄{row[camera_main]}长焦{row[camera_tele]} f电池{row[battery]}充电{row[charge]} f重量{row[weight]}主要特点{row[features]}。 ) docs.append(Document( texttext, metadata{ brand: row[brand], model: row[model], price: float(row[price]), year: int(row[year]), }, )) return docs这段代码里有两件事很重要。第一正文 text 必须是完整的自然语言描述这决定了检索能不能命中。第二metadata 保留 brand、price、year这样后面做路由和过滤时可以直接用不用重新解析文本。数据建模阶段多花 10 分钟后面能省很多麻烦。2.2 构建索引与查询引擎数据文档准备好之后剩下的事情就简单了。切分、向量化、建索引LlamaIndex 都可以在几行代码里完成。from llama_index.core import VectorStoreIndex # splitter对结构化产品表chunk 不需要太大。 # 一条手机数据通常 200 字左右chunk_size512 够用 # 既能保证一条记录尽量在一个节点里又不会切碎完整信息。 splitter SentenceSplitter(chunk_size512, chunk_overlap0) nodes splitter.get_nodes_from_documents(docs) # embed_model 换成你常用的中文 embedding 即可 # 本地开源模型或云端接口都行只要 LlamaIndex 能调用。 index VectorStoreIndex(nodes, embed_modelembed_model) query_engine index.as_query_engine(similarity_top_k4, llmllm) resp query_engine.query(预算 3500 左右拍照好一点的手机推荐哪款) print(resp)这里有一个容易踩的坑不要以为 top_k 越大越好。对于手机参数表每个节点代表一条完整机型信息取 top 3 到 5 就够了。取太多会把不相关的机型也拉进来LLM 生成答案时反而容易被无关参数干扰。另外chunk_size 没必要设得特别大因为你希望一个节点就是一个“完整机型”而不是把多台手机拼进一个节点否则检索到节点后模型要自己分辨是哪个品牌错误率会明显上升。查询输出大概是这样的“根据目前产品信息3500 元价位段推荐云途 M10售价 3999 元5000 万像素主摄电池 5500mAh主打商务长续航如果预算下探到 3000 元可以考虑山岚 B8……”只要检索到的节点是对的稍微调一下提示词就能得到稳定格式。2.3 单机器人的能力边界哪些问题它回答不了基础问答版本跑通后我先不急着往上加复杂功能而是拿真实客服问题去压它。这一压就发现了三个问题。第一个问题是张冠李戴。用户问“极昼 A15 和云途 M10 哪个拍照好”单机器人可能会把极昼的参数安到云途头上因为两个品牌的数据都在同一个索引里语义相近的片段相互干扰。第二个问题是品牌偏好没有被尊重。用户明确说“我只想看极昼”系统却可能因为语义相近把云途的产品推出来。第三个问题最致命用户问完“有货吗”之后紧跟着说“帮我下单”单机器人完全没法执行因为它只有检索能力没有调用业务系统的能力。这三个问题对应三个升级方向品牌路由、多品牌对比、Agent 工具调用。我先做了品牌路由因为这是最容易立刻改善问答效果的一步。3. 第二层品牌专家路由——让问题自动转给对应品牌3.1 为什么要按品牌拆索引而不是全塞进一个索引把三个品牌的数据塞进同一个向量索引确实最省事。但客服场景天然是分品牌的极昼用户关心影像云途用户关心续航山岚用户关心性价比话术和促销政策也可能完全不同。全量索引的检索结果往往是“三个品牌各取几条”回答时容易出现品牌信息串味。按品牌拆索引后每个品牌都有自己的知识库和查询引擎极昼引擎只回答极昼的问题云途引擎只回答云途的问题。这样路由一旦判断准确答案的纯度会高很多而且每个品牌后续可以独立挂接自己的促销接口、售后政策互不影响。brand_engines { 极昼: index_ji_as_engine, 云途: index_yun_as_engine, 山岚: index_shan_as_engine, }这里的 index_ji、index_yun、index_shan 是怎么来的就是从最开始那份数据里按 brand 字段筛选后各自构建出来的代码和 2.2 完全一致只是数据换成了对应品牌的行。一份数据集这时候开始展现出复用的价值。3.2 两种路由实现查询引擎路由与函数工具路由路由最简单的实现是用 LlamaIndex 的 RouterQueryEngine。它本质上是一个“路由器”根据用户问题自动选择最合适的一个查询引擎。from llama_index.core.query_engine import RouterQueryEngine from llama_index.core.tools import QueryEngineTool router_engine RouterQueryEngine( query_engine_tools[ QueryEngineTool.from_defaults( brand_engines[极昼], description查询极昼品牌手机的型号、参数、价格与特点, ), QueryEngineTool.from_defaults( brand_engines[云途], description查询云途品牌手机的型号、参数、价格与特点, ), QueryEngineTool.from_defaults( brand_engines[山岚], description查询山岚品牌手机的型号、参数、价格与特点, ), ] ) resp router_engine.query(云途 M10 电池多少毫安)这种做法的好处是代码量少适合只想先看到效果的场景。但它的短板也很明显路由器本身是黑盒你不好控制“跨品牌问题”和“品牌未识别问题”的走向。所以我更推荐第二种做法把路由逻辑做成一个函数工具交给 Agent 使用。这样路由判断的细节可以自己控制而且后续升级到多智能体时这个工具可以直接复用。def ask_brand_expert(question: str, brand: str) - str: 查询指定品牌手机的型号、参数、价格等事实问题。 if brand not in brand_engines: return f当前没有品牌[{brand}]的知识库请向用户说明无法回答。 return str(brand_engines[brand].query(question))再加上一个品牌识别函数先用规则匹配品牌关键词匹配不到就返回 unknownBRAND_LIST [极昼, 云途, 山岚] def detect_brand(question: str) - str: for brand in BRAND_LIST: if brand in question: return brand return unknown规则匹配的好处是不消耗大模型调用几乎零延迟。真实场景里的品牌名可能被写错或省略这时候再用 LLM 做二次识别补漏。我的经验是能用规则就用规则规则覆盖不到的才交给模型这样路由稳定性反而更高。3.3 路由兜底未知品牌、跨品牌、多品牌怎么处理路由做完后测试问题集里冒出三类特别容易翻车的情况。第一类是用户没提品牌只说“3000 元以下推荐一台”。这不该路由到某个品牌专家而应该走全量索引做综合推荐。我的做法是检测到 unknown 时调用一个默认的全量引擎兜底不做品牌切分。第二类是跨品牌对比用户问“极昼和云途谁续航好”。如果硬路由给其中一个品牌答案就会偏颇。正确的做法是在系统提示词里要求路由 Agent 发现多品牌意图时先分别查询两个品牌的资料再整理成对比答案。第三类是用户用别名提问比如“主打影像的极昼旗舰”问题里没有出现完整品牌名。这时规则匹配会失败需要依靠 LLM 补充识别。兜底策略不是越复杂越好关键是给“失败”留一个出口。我在路由函数里永远保留一个“未识别”分支宁可回答“请稍等我来查一下综合资料”也绝不硬猜品牌。硬猜一次用户信任度就掉一次。4. 第三层多智能体协作下单——从“能回答”到“能办事”4.1 三个角色产品专家、库存专员、下单专员到了这一步系统要从“回答问题”升级成“完成任务”。用户提出“帮我查云途 M10 有没有货有货就下单”时涉及三类知识机型信息、库存状态、下单动作。如果所有逻辑都塞在一个 Agent 里提示词会越来越长工具一多就容易互相干扰。所以我把职责拆成三个角色每个角色只负责一件事。Agent职责拥有的工具不负责的事产品专家回答机型、参数、价格、优缺点品牌知识检索库存、下单库存专员查询某机型在某城市是否有货、预计发货时间库存查询推荐机型、下单下单专员创建订单、确认收货信息下单工具机型推荐、库存这样拆的好处是每个 Agent 的 system prompt 可以写得非常收敛。产品专家不需要知道订单表结构下单专员也不需要理解摄像头像素。哪个环节出了问题定位时一眼就能看出是哪个角色的责任。4.2 工具封装让 Agent 拥有可以调用的“手”多智能体的基础是工具。工具的名字、参数、说明都是写给 Agent 看的所以 docstring 必须说清楚“这个工具能做什么、参数要求什么、什么时候不能用”。def search_knowledge(question: str, brand: str) - str: 从对应品牌知识库检索机型、参数、价格、特点。 只能回答知识库中存在的信息不存在时如实说明。 if brand not in brand_engines: return f没有品牌[{brand}]的知识库。 return str(brand_engines[brand].query(question)) def check_stock(model: str, city: str) - dict: 查询指定城市某型号是否有现货返回库存状态。 注意model 必须是完整型号名例如云途 M10。 stock_table load_stock_table() rec stock_table.get((model, city)) if rec is None: return {in_stock: False, eta_days: None} return {in_stock: rec[count] 0, eta_days: rec[eta_days]} def place_order(customer_name: str, model: str, city: str, address: str) - dict: 创建订单。调用前必须得到用户明确确认否则禁止调用。 order_id create_order_record(customer_name, model, city, address) return {order_id: order_id, status: created, model: model}一个问题值得注意工具返回格式尽量统一我用 dict并且把是否成功、失败原因都放进返回里。千万不要让工具在失败时抛异常Agent 一旦遇到异常容易自己编造结果。我把返回改为包含 in_stock 与 eta_days 这样的结构Agent 拿到后能直接组织自然语言回复不需要二次猜。4.3 先跑通总控 Agent再升级为多智能体 Workflow如果一上来就上三个 Agent出了问题你都不知道找谁。我的做法是先写一个总控 Agent把三个工具全部挂在一个 Agent 下跑通流程确认工具本身可靠后再拆成独立角色。两个阶段我都写出来。总控 Agent 版本from llama_index.core.agent.workflow import FunctionAgent from llama_index.core.tools import FunctionTool knowledge_tool FunctionTool.from_defaults(fnsearch_knowledge) stock_tool FunctionTool.from_defaults(fncheck_stock) order_tool FunctionTool.from_defaults(fnplace_order) controller FunctionAgent( name客服总控, description手机客服总控负责回答产品问题并协助用户下单。, system_prompt( 你是手机客服总控。回答必须引用检索到的真实参数 用户未明确表达下单意向前禁止调用下单工具 订单必须包含客户姓名、机型、城市、地址缺一不可。 ), tools[knowledge_tool, stock_tool, order_tool], )跑通后再拆成三个独立 Agent用 AgentWorkflow 编排。核心变化是每个 Agent 只拿自己的工具通过移交机制把任务交给下一个角色。from llama_index.core.agent.workflow import AgentWorkflow, FunctionAgent product_expert FunctionAgent( name产品专家, description负责回答手机参数、价格、拍照、续航等知识问题。, system_prompt只回答知识库能查到的事实不承诺折扣不下单。, tools[knowledge_tool], can_handoff_to[库存专员, 下单专员], # 不同版本参数名可能不同以官方文档为准 ) stock_specialist FunctionAgent( name库存专员, description负责查询指定城市和型号的库存情况。, system_prompt只查询库存不报价不下单查完结果交给其他角色。, tools[stock_tool], can_handoff_to[下单专员], ) order_specialist FunctionAgent( name下单专员, description负责创建订单确认收货信息。, system_prompt用户未确认前禁止下单。下单前复述订单信息请用户确认。, tools[order_tool], ) workflow AgentWorkflow( agents[product_expert, stock_specialist, order_specialist], root_agentproduct_expert, timeout120, )协作流程跑起来后大致是用户说“云途 M10 有货吗有就下单”——产品专家先确认机型把查询库存的意图移交给库存专员库存专员返回“有货预计 2 天送达”产品专家告知用户用户回复“确认下单”下单专员校验用户姓名和地址后调用 place_order返回订单号。整个过程每个角色只干自己那件事上下文清晰很多。4.4 下单场景的安全边界确认、幂等、信息脱敏Agent 一旦能触达下单接口安全就是第一优先级。我在这个项目里加了三条硬规则。第一下单前必须用户明确确认。我在 system prompt 里反复强调“用户未确认前禁止调用下单工具”同时在工具函数里也写清楚这个前提。有些用户只是问“能不能下单”并不是真的想下Agent 不能自作主张。第二订单接口要做幂等。如果 Agent 因为网络抖动重试了一次不应该产生两笔订单。我在 create_order_record 里用 customer_name model city address 的哈希做订单去重键重复提交返回同一个订单号。第三日志脱敏。Agent 在对话过程中会拿到用户姓名、电话、地址调试日志里绝不能原样打印。我在工具返回和日志记录里把地址数据截断成“市 区 小区前两个字”电话号码只保留后四位。这个习惯即使项目只是在 Demo 阶段也需要养成后期迁移生产时能少踩很多合规的坑。5. 全流程复盘评估方法、检索调优与多智能体 Debug 心得5.1 用一小组问题集检验三层链路整套链路搭完之后最关键的步骤是评估。我先准备了一份 20 条问题的小测试集覆盖三类场景单品牌参数查询、跨品牌对比、完整下单流程。每次改完代码就跑一遍这 20 条记录下面几个指标。测试类型关键指标我遇到的主要问题单品牌参数查询回答中的型号、参数是否正确检索到错误品牌节点跨品牌对比是否分别引用两个品牌真实信息只路由到单一品牌下单流程是否产生正确订单号用户未确认就尝试下单未知问题是否诚实说不知道Agent 编造不存在的机型这个测试集不大但非常有效。它帮我发现了一个很隐蔽的问题有两条下单测试里用户地址被工具调用时丢了一个字段Agent 没有主动追问而是默默用“未知地址”直接下单成功。后来我在 system prompt 里强制要求“创建订单前必须逐项确认姓名、地址、数量”并在 place_order 内部增加参数校验缺字段就返回错误这个 bug 才彻底堵住。5.2 检索质量最容易被忽略的三个细节第一是 top_k 的选取。刚才在单机器人部分说过top 3 到 5 比较好但对路由后的品牌索引每个索引数据量变小top_k 可以降到 3减少噪音。第二是元数据过滤。用户给出预算区间时不要只靠向量相似度去猜而是先用 metadata 里的 price 字段过滤一遍再进向量检索准确率会高出不少。第三是切块粒度。对于产品表这类“一条记录一个完整答案”的数据千万别用默认的长文本切分否则一条手机数据可能被切成两半导致回答缺失电池或摄像头参数。很多搜索质量问题的根源其实都在 data modeling而不是模型本身。检索结果错了换再强的 LLM 也只能把错的内容编得更流畅。5.3 多智能体跑起来之后怎么调试多智能体调试和普通 RAG 调试完全不一样。RAG 出问题你看检索节点和生成答案就行多智能体出问题你还得看每个 Agent 理解了什么、调用了什么工具、把任务移交给了谁。我的经验有三个。第一开启完整事件日志。AgentWorkflow 支持事件回调把每一步的输入输出都打出来。第二给 workflow 设置 timeout我设了 120 秒超过就终止避免 Agent 在工具调用里死循环。第三限制重试次数。Agent 调用工具失败时最多重试一次否则它可能反复尝试白白消耗 token 和时间。还有一个排查技巧如果 Agent 回答里出现某个参数但你知道检索结果里没有这个信息大概率是 LLM 幻觉而不是 Agent 逻辑问题。这时候不要急着改 prompt先去看它实际检索到的节点内容往往问题出在 embedding 模型对专业词汇的相似度区分不够或者切块把关键数字切丢了。整个链路跑下来我最深的体会是多智能体的难点不在于“让多个 Agent 聊天”而在于把数据边界、工具边界、职责边界划清楚。产品表一份路由一层工具一层Agent 一层每一层之间都靠清晰的接口连接。你加一个品牌只需要往数据里加几行往 brand_engines 里加一个引擎你加一个业务动作只需要写一个新的 FunctionTool。这种结构化的演进方式比一开始就堆复杂架构要省心得多。最后再分享一个小技巧在项目初期我会把每个版本的测试结果记录在一个表格里备注是哪一次改动导致指标上升或下降。多智能体系统的不确定性比普通 RAG 高很多没有记录你很难判断到底是 prompt 的问题、工具的问题还是数据的问题。拿同一份手机机型数据从单机器人问答做到品牌专家路由再到多智能体协作下单这套路径值得每个打算深入 Agent 应用的人亲手跑一遍。