ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring AI 2.0 链路改造:用意图识别提升工具调用准确率

Spring AI 2.0 链路改造:用意图识别提升工具调用准确率 做一个问答服务技术栈 Spring AI 2.0.1 / Spring Boot 4.1 / WebFlux。它既要查知识库也要调一批外部工具拿实时数据碰到的麻烦是模型经常不肯调工具。记录一下包括几条走不通的路和最后怎么拆的。说明本文是个人项目里的实践记录方案和结论仅供参考不保证适用于其他场景请结合自己的业务谨慎评估。文中的数字都来自实测样本量见「效果」一节。代码为说明思路做了简化类名是示意性的。原来的链路一个ChatClient挂 RAG 顾问、记忆顾问和工具顾问顺序值小的在外层记忆顾问 → 工具顾问 → RAG顾问 → 模型工具顾问在外层而且它是个循环RAG 顾问在内层。所以工具循环每绕一圈检索和重排就跟着重跑一次——这是 Spring AI 原生的行为用户问题 └─ 第 1 圈RAG检索 → 模型 → 发起工具调用 执行工具 第 2 圈RAG检索(重跑) → 模型 → 生成回答一次带工具的问答检索和重排各跑两次。问题也就出在这里模型决定要不要调工具的那一刻资料已经塞进当轮的 user 消息了。当时的提示词是信息来源有【知识库资料】与【工具】两类不是二选一资料里有的用资料有能处理本问题的工具就用工具两者都相关就都取、结合后给出最完整的答案。这句话模型完全可以读成资料里有就够了。只要检索命中任何东西哪怕是不相干的文档它就有了不调工具的正当理由。实测表现带坐标、半径的查询稳定调工具“有哪些 X”X 在哪这类清单式提问一次都不调转而去编还会把命中的无关文档当参考资料列出来。试过的几条路改提示词。改成工具在先、资料补充后同族问句成功率 0/3 → 3/3同一天在别处改措辞又掉回 0/4。0% 和 100% 一天之内都能复现。往工具 description 里写注意事项。加了一句输出不要展示给用户同问句的调用次数从 1 掉到 0。description是紧挨着选哪个工具被读到的在那儿写输出的注意事项会把要不要选它一起压下去。工具描述只该写能力边界。禁令只禁措辞。原来写不要声称自己调用了工具模型就换词说我帮您查了系统收录的信息。改成禁行为——不得声称已查询、检索或核实过——才有用。怎么拆的思路把一个ChatClient拆成两个中间用有没有取到数据这个结构信号来分流。整条改造路线是这样请求 │ ├─ ① 路由段routerClient只挂工具看不到任何资料 │ 工具调用会经过一层自己的回调 → 顺手把结果记进账本ledger │ 账本有没有内容 本轮到底取到数没有 │ └─ ② 作答段answerClient挂 RAG 记忆一个工具都不挂 检索链最外层套了闸门账本非空 ⇒ 直接返回空检索与重排根本不跑 渲染时二选一取到的数据 或 检索到的资料 记忆只在这一段写一共动四个地方两个ChatClient的装配、检索链、渲染器、编排。按装配顺序走一遍。1. 两个 ChatClientSpring AI 的顾问是一条有序链按 order 排序值小的在外层工具顾问本身是循环。原链路里工具顾问在外层、RAG 顾问在内层所以每绕一圈检索就重跑一次。要绕开这个结构只能把两者分到两个 client 上ConfigurationclassChatWiring{// 路由段只挂工具不挂 RAG、不挂记忆Bean(routerClient)ChatClientrouterClient(ChatClient.Builderbuilder,ListITooltools){returnbuilder.defaultAdvisors(newTimingAdvisor(route))// 只采耗时.defaultTools(tools.toArray()).defaultSystem(routingPrompt)// 只讲“判断要不要用工具”.build();}// 作答段挂 RAG 记忆一个工具都不挂Bean(answerClient)ChatClientanswerClient(ChatClient.Builderbuilder,RetrievalAugmentationAdvisorragAdvisor,MessageChatMemoryAdvisormemoryAdvisor){returnbuilder.defaultAdvisors(ragAdvisor,memoryAdvisor,newTimingAdvisor(answer)).defaultSystem(answerPrompt)// 无工具版.build();}}2. 检索链闸门套在最外层装饰器从内往外套闸门放最外面——它最先决定这一轮要不要检索BeanDocumentRetrieverdocumentRetriever(VectorStorevectorStore){DocumentRetrieverretrieverVectorStoreRetriever.builder().vectorStore(vectorStore).build();retrievernewRerankingRetriever(retriever);// 重排可选retrievernewFetchGateRetriever(retriever);// 本轮取到数就直接短路returnretriever;}// 闸门账本非空 ⇒ 检索与重排整体不发生而不是“跑了不用”classFetchGateRetrieverimplementsDocumentRetriever{privatefinalDocumentRetrieverdelegate;OverridepublicListDocumentretrieve(Queryquery){FetchLedgerledgerquery.context().get(LEDGER_KEY);if(ledger!null!ledger.isEmpty()){returnList.of();}returndelegate.retrieve(query);}}3. 渲染器两种信息二选一信息只有两种取到的数据或检索到的资料互斥。框架自带的ContextualQueryAugmenter用不了它是替换语义会丢原问题空上下文分支还不传变量所以自己写一个classTwoSourceAugmenterimplementsQueryAugmenter{OverridepublicQueryaugment(Queryquery,ListDocumentdocuments){FetchLedgerledgerquery.context().get(LEDGER_KEY);if(ledger!null!ledger.isEmpty()){returnrender(contextTemplate,query,【本轮已取得的数据】\nledger.text());}if(!documents.isEmpty()){returnrender(contextTemplate,query,【本轮检索到的资料】\nformat(documents));}returnrender(emptyTemplate,query,);// 既无数据也无资料}}信息块要放在本轮行为约束之前——同一个模板越靠后权重越高把一大段数据追加到约束后面等于把约束挤到中间。还有个别扭的写法要避开别把取数结果当成额外追加一条 user 消息。记忆顾问在最外层会把 prompt 里的 user 消息写进历史取数结果就会逐轮重现走模板渲染是内层替换不会回流出去。4. 取数记账账本 回调装饰器判据不靠模型说什么靠我们自己的回调记账。账本随本次请求的toolContext走回调装饰器拿到结果顺手记一笔// 本轮取数结果classFetchLedger{privatefinalListStringitemsnewArrayList();voidadd(Stringtool,Stringtext){items.add(text);}booleanisEmpty(){returnitems.isEmpty();}Stringtext(){returnString.join(\n,items);}}// 给每个工具回调套一层调用照旧顺便记账classLedgerToolCallbackimplementsToolCallback{OverridepublicStringcall(Stringinput,ToolContextcontext){Stringoutdelegate.call(input,context);FetchLedgerledgercontext.get(LEDGER_KEY);if(ledger!nullisEnvelope(out)){ledger.add(delegate.getToolDefinition().name(),out);}returnout;}}Spring AI 也有现成的结构信号ChatResponse.hasToolCalls()但ToolCallingAdvisor默认会自己执行工具、循环到没有工具为止最终响应里toolCalls恒为空想用它得先关掉自动执行internalToolExecutionEnabled(false)再自己循环。我没走这条因为记账不用去动 options 的合并行为。5. 编排两段串起来ServiceclassChatOrchestrator{FluxEventchat(Stringquestion){varledgernewFetchLedger();// ① 路由段先跑阻塞内部可能串多次工具往返routerClient.prompt(newPrompt(question)).toolContext(Map.of(LEDGER_KEY,ledger)).call();// ② 作答段检索链会自己读账本决定要不要检索returnanswerClient.prompt(newPrompt(question)).advisors(a-a.param(LEDGER_KEY,ledger)).stream().map(/* 转成前端事件 */);}}两段必须串行。作答段的内容依赖路由段的结果而且流是单向的发出去收不回反过来先作答、之后才发现该调工具用户会看到前后矛盾的答案。还有一条只能在装配时定记忆只挂作答段一处。两段都挂就是双写——同一会话每轮会写两条问、两条答而窗口只有 10 条路由段那些不需要工具的调度文本会把窗口占掉一半。路由段只读历史解析这条那艘船的指代但从不落笔。又退化了一次拆完头一天是好的第二天就现了原形。同一句某船周围 10 海里有哪些碍航物前一天调了 4 次工具第二天同样的会话、同样的问法取数段直接判成不用查、跳过去走检索了。原因在路由段挂着历史。为了让这条“那艘船这种指代能解析路由段把历史拼进了 prompt。可历史里已经存了前一天这条船周围有哪些的完整问答模型一看这个问过了”就把它当成了不调工具的理由。这里的教训是路由段那个不用查的出口语义得收得足够窄。它只该留给纯知识问答这一类不能被模型扩成历史里查过了。另外还改了另一件坑最初的路由提示词里手写了一串能力清单查 A、查 B、查 C……。后来发现这是给自己挖坑——模型本来就看得见每个工具的名字和说明这串清单是多余的而且工具一扩展就得回来改提示词。所以改成以工具列表为准只留一句性质判据工具只用于查实时数据除此之外不调。改完这几处同一会话脏历史下再测恢复稳定。调用次数的增减原链路两段式无工具轮 · 模型调用12无工具轮 · 检索 / 重排1 / 11 / 1有工具轮 · 模型调用23有工具轮 · 检索 / 重排2 / 20 / 0简化掉的工具循环里重复的检索和重排。原链路每个工具轮都会把检索、重排重跑一遍两段式里带工具的轮次这两项直接归零。增加的每轮多一次很短的模型调用就是路由段做意图识别的那一次无工具轮它只回一个停止标记不写正文。效果数据来自会话表里本轮有没有发起过工具调用的字段。同一个问句单链路 0/4 → 两段式 2/2。同族问句在单链路下的抖动0/3 → 3/3 → 0/4同一天。改造后一个窗口 14 轮该调工具的 10 轮全调不该调的 4 轮全不调。也不再出现谎称已查询和编造条目。内部标识流不到用户看得见的文本——作答段没有工具、也没有参数需求渲染前把那行整段剥掉就行。样本量是 14 轮、单会话够说明方向不够说明稳定。总结几点工具和 RAG 放同一轮两者会互相解释资料也有了就成了不调工具的正当理由。提示词改不动就别接着改先用同一族问句多跑几次看稳不稳0% 和 100% 都能复现缺的是结构。判据用结构别用标记词能让代码决定的事别交给模型。工具description只写能力边界输出怎么呈现的要求放返回文本里。禁令禁行为别禁措辞。路由段挂了历史历史里的答案也会变成不用调工具的理由。那个不用查的出口语义得收窄到只留给纯知识问答。别在提示词里手写工具能力清单——模型本来就看得见工具列表写死清单工具一扩展就得回来改。要写边界就写工具的性质“只查实时数据”不写具体工具名。
返回列表