2026-09-20.md 11 KB

2026-09-20

把 module/agent/tools 的全部 Agent 工具通过 Spring AI MCP 暴露给外部

需求:com.zsjz.ai.module.agent.tools 下的所有 tools 都要能通过 Spring AI MCP 给外部客户端用。

方案(适配而非重写):新增包 com.zsjz.ai.module.agent.mcp,3 个类:

文件 职责
AgentScopeToolCallback 把 AgentScope 工具包成 Spring AI ToolCallback(ToolDefinition 直通 AgentScope 生成的 JSON Schema)
AgentScopeMcpToolProvider @Component implements ToolCallbackProvider,另建一个独立 Toolkit(与内置 Agent 的实例隔离),注册同一批工具对象 + 5 个业务组全部激活,另附 4 个案件管理工具
McpCaseSession MCP 会话级「当前案件」绑定(按 McpSyncServerExchange#sessionId()),开案走 CaseDataSourceRegistry.open + CaseDataCache.initCache,不写 sa-token Token-Session(不干扰 Web 端)

关键结论:

  • 零手写:新增/改工具只需在 AgentToolRegistry 注册,MCP 侧自动出现;两边 schema 同源不会漂移。
  • 案件上下文是硬需求:业务工具走 @DS("slave"),由 CaseRoutingDataSource 按 CaseContextHolder 路由到 case{caseId}。MCP 无登录态/无请求线程 ⇒ 调用前用 CaseContextHolder.callWith(caseId, null, supplier) 显式包裹。
  • 必须放行 sa-token:/mcpsse、/mcpstreamable 已加入 SaTokenWebConfig.WHITELIST(MCP 握手不带 x-token)。⚠️ 端点无鉴权,需外层限制来源。
  • spring.ai.mcp.server.type: async ⇒ McpToolUtils.toAsyncToolSpecification 用 Schedulers.boundedElastic() 执行工具,所以在 call 里 .block(timeout) 是安全的。
  • McpToolUtils 的 ToolContext 里带 McpSyncServerExchange(key = McpToolUtils.TOOL_CONTEXT_MCP_EXCHANGE_KEY),sessionId() 可拿会话 ID。
  • 工具返回值不做二次 JSON 序列化(ToolResultBlock → TextBlock 文本直出),与内置 Agent 一致。
  • Json.toStr 会把 Long 序列化成字符串("caseId":"7"),写断言时别按数字写。

测试:AgentScopeMcpToolProviderTest(9 例)——全量暴露且无重名、每个工具都能 McpToolUtils.toAsyncToolSpecification(这一步是 schema 兼容性的真正回归点,失败会让 MCP server 起不来)、render_graph 真实调用链路、入参校验错误文案、4 个案件工具与会话绑定、标量列表回归。

端到端验证结果(8981 临时实例,标准库 Python 探针)

GET /js/a/mcpsse → event:endpoint → POST /js/a/mcp/message?sessionId=… 全链路打通:

  • tools/list = 74 个工具,0 重复,全部 inputSchema.type == object (64 业务 + 6 基础设施含 search_table_schema + 4 案件管理)
  • render_graph 返回规范化后的图谱 JSON(未被二次转义)
  • list_cases → open_case 5 → execute_sql 查 call_record = 38263 行、 person_basic_info = 7 行;换案件 9 同样查询 = 0 行 ⇒ 案件上下文按 MCP 会话隔离、@DS 路由正确
  • close_case 正常释放

★ base-url 必须配(否则外部客户端全部 404)

SSE 首帧下发的是 data:/mcp/message?sessionId=…(相对路径,不含 context-path), 客户端会拼成 http://host:8980/mcp/message → 404。 spring.ai.mcp.server.base-url: /js/a 修好后变成 /js/a/mcp/message?…。

另注:spring.ai.mcp.server.protocol 默认 SSE,此时 streamable-http.mcp-endpoint 不注册; 端点只接受对应方法(SSE 端点只认 GET,用 POST 探会得到 404,容易误判成「配置没生效」)。

★ 顺手修掉一个既有 bug:list_person_names 在有人名的案件上必然报错

ToolResultTable.toRows 按「元素都是 POJO」处理,对 List<String>(姓名清单)会抛 Cannot construct instance of java.util.LinkedHashMap ... from String value。 空人名时返回空列表所以一直没暴露,MCP 链路上在案件 5(7 个人名)实测到。

  • ToolResultTable.toRows 改为逐元素处理,标量(String/Number/Boolean/Character)兜底成单列 value
  • PersonAnalysisTool.listPersonNames 显式包成单列 name

既有测试失败(与本次改动无关,未修)

AgentToolRegistryTest 5 例失败,原因是 AgentToolRegistry 里 OTG 组被注释、工具清单未做完 (实际 64 个业务工具,测试按计划中的 77 个断言):registersAllBusinessTools(NPE)、 activatingOneGroupRevealsOnlyThatGroup、metaToolEnumListsAllGroups、 noSchemaLeaksInjectionSurface、numericSpecFieldsBecomeJsonNumbers。 另 4 个测试类(Call/GraphRender/Sql/Track)全绿。


新增:直连大模型 service(com.zsjz.ai.module.agent.llm)

需求:封装一个不经过 Agent、直接调大模型拿结果的 service。

产出: | 文件 | 职责 | |---|---| | LlmService | chat / chatDetail / chatAs / stream,核心是把 Model#stream 的分片拼成文本 | | LlmRequest | @Builder(toBuilder=true):modelId / system / user / messages / temperature / maxTokens / timeout(默认 60s) | | LlmResult | record:text / modelId / modelName / inputTokens / outputTokens / elapsedMs / totalTokens() |

设计要点:

  • 模型配置复用 agent_model 表(⚠️ 表名是 agent_model 不是 model),走 AgentModelFactory.create(), 与 Agent 链路同一套连接参数;不传 modelId 时取默认对话模型。
  • 失败语义一律抛 ServerException(400/404/500/504),绝不返回 null —— 与 IntentService/FollowupService 的 fail-open 刻意相反(这是调用方主动要结果,不是辅助链路)。
  • chatAs 不用 AgentScope 的 getStructuredData(那要 ReActAgent 的 generate_response 工具), 改为「提示词注入 schema + 宽松解析」(剥 ``` 围栏、截最外层 JSON)。

踩到并修掉的坑:Flux.blockLast(Duration) 把流内任何错误都包成 IllegalStateException("Timeout on blocking read...") ⇒ 模型 401 被误报成 504。 改为 .timeout(...) + .onErrorMap(TimeoutException.class, …)。

验证(19 例 mock 单测 + 6 例真实 HTTP 测试,全绿): 本机 Ollama 未启动(11434 无监听)、且 shell 有 http_proxy=127.0.0.1:58859, 默认模型 Ollama:qwen 连不上。于是新增 ai-server/src/test/resources/mock-openai-server.py(HTTP/1.1 chunked 手写 SSE 的假 OpenAI 端点)

  • LlmServiceHttpTest(不启 Spring 上下文,秒级)真实跑通 「DB 配置 → AgentModelFactory → HTTP SSE → 分片拼接 → usage 提取 → 错误码映射」。 真厂商端点的 LlmServiceLiveTest 因本机无可用模型未验证。

顺带发现(未修):com.zsjz.ai.module.plat.mapper.PhoneIspMapper 是该包下唯一漏 @Mapper 注解的接口,本项目不用 @MapperScan ⇒ GlobalCache#initIspData() 启动时抛 NoSuchBeanDefinitionException,被 AppLoadEndEventListener try-catch 吞掉(日志「初始化系统文件失败」), 代价是手机号运营商映射从未加载。测试里用 @MockitoBean 绕过。

环境提示:探测本地端口必须 curl --noproxy '*',否则 http_proxy 会把请求转给代理并返回 502。


上传文件后缀放开 + 非表格文件登记为「无法读取」

需求:上传时允许的后缀范围扩大,加上常见文件(txt / md / doc / docx / pdf 等); 只要不是 xls / xlsx / csv 就默认上传失败,但仍记录到文件表中,状态记为「无法读取」。 范围限定:只改「案件数据上传」(case/views/data/upload.vue + DmService)。

核心设计 —— 两级白名单(这是最容易搞混的地方): | 常量 | 含义 | 内容 | |---|---|---| | GlobalCache.uploadSuffixList(新增) | 可上传 | txt csv md doc docx pdf xls xlsx | | GlobalCache.suffixList(既有) | 可解析 | xls xlsx csv |

两者之差(txt/md/doc/docx/pdf)→ 正常落盘 + 落库,fileStatus = FILE_UNSUPPORTED_FAIL("文件格式不正确")。 uploadSuffixList 之外 → 仍抛「不支持的文件类型」(前端已按同一清单过滤,后端这层防的是绕过前端直接调接口)。 ⚠️ 前端 VALID_UPLOAD_EXTS 必须与 uploadSuffixList 同步,改一处要改另一处。

后端改动:

  • GlobalCache:新增 uploadSuffixList
  • DmService#preProcessUploadedFile:白名单校验提到落盘之前(否则会留下无人认领的垃圾文件); 不可解析的分支新增 buildUnreadableFileInfo,落盘 + saveParsedFileInfos + 返回失败节点
  • DmService#buildUnreadableFileInfo(新增):构造 pid=0、无 children 的失败节点, fileType 记真实后缀(前端显示成 报告.docx · docx)
  • 这类文件刻意不做 MD5 去重 —— 去重是为了省解析算力,它们不解析;去重反而让用户第二次上传什么都看不到

关键实现依据(先验证再动手):

  • ExcelTypeEnum.recognitionExcelType() 按文件内容魔数判类型(XLSX=zip / XLS=OLE2 / 否则一律 CSV), 不按扩展名 → 放宽后缀不会让解析器错乱
  • file_info 是案件 DuckDB 库的表(sql/case_table_1.sql),NOT NULL 字段: id / pid / fileSize / dataNum / successNum / failNum —— 全部已赋值
  • 删除路径安全:deletedFiles 按 fileId 删业务表数据,失败节点删 0 行不报错
  • 清洗页安全:cleanProgress.vue 的 findBatchId 对 children 做了数组检查

顺带修掉一个由本次改动引入的边界问题: 失败节点也会进 resultTrees,而顶部「手动清洗」按钮原本只判断 resultTrees.length —— 用户只传一个 docx 时按钮会亮起,点进去是空的手动清洗页。 已改为新增 hasCleanableSheet(要求至少一个根节点有 children),并同步 handleManualClean 的守卫。 上传完成的 toast 也改为按「真正解析出 sheet 的数量」提示,避免对失败文件谎报「成功解析 N 个文件」。

验证:后端 mvn compile BUILD SUCCESS;前端 vue-tsc 中 upload.vue 零错误 (全项目 89 个既有错误均在 trans 等模块,与本次无关);eslint + prettier 通过。

下载口径已同步修掉:SystemService#resolveDownloadFile 原本用 suffixList(可解析)校验, 会出现「能传不能下」。已改用 uploadSuffixList,错误文案改为「该文件类型不支持下载:{文件名}」。 前端下载按钮没有后缀条件(由后端校验),无需改动。

遗留(未改):

  • DmService#isSupportedPreviewFile(/preFile 接口、Electron 遗留路径的文件夹扫描)仍只认三种后缀。 它只负责「哪些文件值得解析」,不是上传入口;Web 端「选择文件夹」走 preFileUpload,故保持不变。