📌系列说明:一个 Java 后端视角的 Spring AI 渐进式实战教程,载体为开源项目「劳小司 · 智能法律助手」。
- 序章:技术栈全景与 AI 学习指南
- 阶段一 · 流式对话内核:篇1 SSE 流式·停止·思考可见化 / 篇2 会话记忆压缩与滚动体验
- 阶段二 · 工具调用:篇1 Function Calling 与法律计算器 /篇2 联网搜索与工具预算(本文)
- 阶段三 · RAG 知识库:篇1 起步与底账化 / 篇2 Agentic RAG 与引用可信度 / 篇3 检索质量与体验
- 阶段四 · 多模型路由:篇1 五路级联路由
- 阶段五 · 安全与质量门:篇1 安全层与强制检索 / 篇2 质量门与评估门禁 / 篇3 指代消解与阻塞隔离
- 阶段六 · 产品化与用户体系:篇1 认证·配额·门禁 / 篇2 前端·移动端·身份 / 篇3 劳动法专精与多模态
- 阶段七 · 存储演进与部署:篇1 存储迁移 / 篇2 部署契约
📦本篇涉及:
tool/WebSearchTool.java、tool/ToolBudget.java、application.yml(route-tools / web-search 段)。
一、两个新问题
篇1 让模型会算、会查本地库,但带来两个新问题:
- 知识停在训练截止日:2026 年新颁布/修订的政策、最新社平工资,模型不知道——需要联网;
- 工具一多就烧钱:模型可能"先查本地库、再联网",一轮里检索工具调好几次,各管各的预算会翻倍——需要统一预算。
二、联网搜索:LLM-as-Search-Tool
给 AI 应用加联网,直觉是接一个搜索 API(Tavily / 博查)。本项目走了另一条路——让一个自带联网能力的子模型去搜:主模型调webSearch工具,工具内部再调一个开了enable_search的模型,用"法律资料检索员"提示词约束它,返回带来源标注的摘要。
为什么这么选?一笔账:
| 对比项 | 搜索 API | 联网子模型 |
|---|---|---|
| 价格 | 博查约 0.03 元/次 | 按 token 计费,一次检索不到一分 |
| 账号 | 要单独申请、单独计费 | 复用已有的模型 Key,零新增账号 |
| 开发量 | 对接搜索 API + 自己解析结果 | 一个 HTTP POST(body 加个开关) |
代价(知情选择):enable_search是全网搜索,无法像搜索 API 那样在 API 层硬限定域名。于是"只采信官方来源"从硬约束降级为提示词软约束——靠子模型的系统提示词要求它只把 gov.cn、法院官网、裁判文书网等作为结论依据,且逐条标注来源网址供用户核验。联网在本项目里定位是"辅助参考",这个精度够用。
privatestaticfinalStringSEARCH_ASSISTANT_PROMPT="你是法律信息联网检索员……"+"1) 只采信官方权威来源(gov.cn 及其子站、法院官网、裁判文书网……),"+"自媒体仅作线索不得作结论依据;2) 以条目返回:摘要 + 来源网站 + 来源网址 + 日期;"+"4) 官方渠道未检索到就直说,禁止编造来源。";三、为什么手写 HTTP,而不走 Spring AI ChatModel
这是本篇最硬的一个坑:enable_search是厂商的私有 body 参数,Spring AI 的OpenAiChatOptions不透传这类扩展字段。你要是只在 ChatClient 上找"联网开关",永远找不到。
解决:用RestClient直调 OpenAI 兼容端点,在请求体顶层手动加enable_search: true:
Map<String,Object>body=Map.of("model",properties.getLlm().getModel(),"enable_search",true,// 私有参数,Spring AI 不透传,只能手写"messages",List.of(Map.of("role","system","content",resolvePrompt()),Map.of("role","user","content",query)));restClient.post().uri("/chat/completions").body(body).retrieve().body(Map.class);顺带辟谣:网上不少教程里的
spring-ai-tavily-search坐标是虚构的,Spring AI 官方核心库并没有内置联网搜索工具。别照着 mvn 依赖半天找不到。
降级不阻断:联网失败(超时 / 子模型异常)不阻断回答,返回一句"联网搜索暂时不可用,请基于本地知识回答并提醒用户"——与全链路降级原则一致。
四、工具预算:多工具共享一个池
引入联网后,一轮里searchLaw(查本地)和webSearch(查互联网)可能都被调。若各管各的额度,模型"本地查一遍、网上再查一遍",成本直接翻倍。
ToolBudget的设计是共享预算:两个检索工具从同一个ToolBudget扣减,route-tools.max-tool-calls就是本轮所有检索类工具的总调用上限。
publicbooleantryAcquire(){booleanok=used.incrementAndGet()<=maxCalls.get();if(!ok)denials.incrementAndGet();returnok;}预算耗尽时,工具不再执行检索,而是返回一段"劝退"文案让模型基于已得信息作答;连续被拒 ≥3 次还会升级为强制收敛指令,熔断"检索成瘾"的空转:
returnbudget.denialCount()>=ToolBudget.HARD_DENY_AFTER?"检索次数已达上限且连续被拒。【强制】立即停止一切工具调用,基于已检索到的信息直接生成完整回答。":"本轮检索预算已用完,不要再调用 searchLaw 或 webSearch,请基于已有信息作答。";预算的生命周期是请求级:每次对话 new 一个,经ToolContext传给工具,天然隔离;用AtomicInteger兜一层线程安全。审校重试时(阶段五·篇2)系统会grant()追加预算——那是质量门触发的系统行为,不算用户头上。
五、路线差异化挂载:闲聊零工具
预算之外,还有一层更省的设计:不同路线挂不同的工具集。配置在route-tools里:
route-tools:default:{tools:[searchLawTool,webSearchTool],max-tool-calls:6}cheap:{tools:[],max-tool-calls:0}# 闲聊不挂任何检索工具legal:{tools:[searchLawTool,webSearchTool,laborTools,generalLegalTools],max-tool-calls:12}闲聊路线(cheap)一个检索工具都不挂——模型想查也无工具可调,从源头保证零额外成本;法律专业路线才全量挂载、给更高预算。max-tool-calls同时也是 ReAct 循环的停止条件。
六、踩坑备忘
① 私有参数不透传。enable_search、thinking_budget这类厂商扩展字段,Spring AI 的 Options 不认。要透传就得手写 HTTP(本篇),或换用支持的扩展库。这是接入国产模型私有能力时的通用陷阱。
② 多工具各管各的预算 = 双重烧钱。一定让同类检索工具共享一个ToolBudget,别一个工具一个计数器。
③ 预算拒绝文案要"可执行"。光返回"预算用完"没用,模型可能换个说法接着调;要给出明确指令(“基于已有信息直接作答”),连续拒绝时升级为【强制】收敛。
④ 联网范围无法硬限定域名。用 LLM-as-Search 换低成本,就要接受"来源过滤靠提示词软约束",输出必须带来源网址让用户可核验——这是取舍,不是缺陷,但要知情。
七、小结
| 机制 | 一句话 |
|---|---|
| LLM-as-Search-Tool | 用联网子模型代替搜索 API,便宜且零新增账号 |
| 手写 HTTP | 私有参数 enable_search 不被 Spring AI 透传 |
| ToolBudget | 多检索工具共享一个预算池,防双重烧钱 |
| 差异化挂载 | 闲聊路线零工具,专业路线全量 + 高预算 |
八、下篇预告
工具能联网、能查本地了,但"本地知识库"本身还是黑盒——法条怎么向量化、怎么增量同步、检索准不准,都是下一篇开始的正题。阶段三,我们进入 RAG 知识库。
🌐源码与体验:Gitee(国内快)https://gitee.com/spaserby/laoxiaosi.git | GitHub https://github.com/spaserby/laoxiaosi.git
🖥 在线演示:https://laoxiaosi.noctisblue.com
本系列全套代码皆开源,觉得这篇有帮助,欢迎顺手点颗 ⭐