Spring AI 工程化实战从 Prompt 契约到 Python 自动验收报告前面三章已经完成了 Spring AI 的基础对话、Tool Calling、会话记忆、Java/Python 双栈调用和基础 RAG。当接口越来越多以后我遇到了一个新的问题AI 接口能返回内容不代表它能被稳定地验证。普通 REST 接口通常可以直接断言状态码和 JSON 字段但大模型输出存在不确定性。模型、Prompt、知识库或工具描述发生变化都可能让原本正常的接口悄悄退化。所以这一章不再继续增加业务功能而是给现有 AI 接口补一条最小工程闭环Spring AI 输出契约 - Python 读取 JSONL 用例 - httpx 调用 Java 接口 - Pydantic 校验用例和结果 - 关键词断言 - 生成 Markdown 验收报告 - pytest 固化核心逻辑这套代码仍然是学习项目不是完整的企业评测平台但它让我第一次把“AI 能跑”推进到了“AI 可以重复验收”。一、为什么 AI 接口需要契约最开始的接口直接返回StringreturnchatClient.prompt().system(你是企业管网调度 AI 助手).user(message).call().content();这种写法适合聊天但后端很难稳定判断风险等级是什么置信度是否合法是否给出了建议动作当前使用的是哪一版 Prompt本次调用耗时多少出现回归时如何定位。因此我定义了一个结构化返回对象publicrecordStructuredRiskAnswer(JsonPropertyDescription(风险等级只能是 HIGH、MEDIUM、LOW、UNKNOWN)StringriskLevel,JsonPropertyDescription(一句话总结风险判断)Stringsummary,JsonPropertyDescription(建议现场执行的动作列表)ListStringactions,JsonPropertyDescription(置信度范围 0 到 1)Doubleconfidence,StringtraceId,LonglatencyMs,StringpromptVersion){}这里可以把字段分成两类。第一类是模型业务输出riskLevelsummaryactionsconfidence第二类是系统运行元数据traceIdlatencyMspromptVersion后续学习 LangChain4j 时我又进一步确认了一点运行元数据不应该由模型生成最好由 Controller、Advisor 或监听器补充。本章保留当前学习代码的实现并在模型返回后由 Java 统一覆盖这些字段。二、用.entity()接收结构化结果接口核心代码如下GetMapping(/structured-answer)publicStructuredRiskAnswerstructuredAnswer(RequestParamStringmessage){longstartSystem.currentTimeMillis();StringtraceIdUUID.randomUUID().toString();StructuredRiskAnsweranswerchatClient.prompt().system( 你是企业管网调度 AI 助手。 你的任务是根据用户输入判断风险并返回结构化结果。 riskLevel 只能是 HIGH、MEDIUM、LOW、UNKNOWN。 不确定时 riskLevel 返回 UNKNOWNconfidence 不得超过 0.5。 不允许编造不存在的制度、设备状态或审批规则。 ).user(用户问题\nmessage).call().entity(StructuredRiskAnswer.class);longlatencyMsSystem.currentTimeMillis()-start;returnnormalizeAnswer(answer,traceId,latencyMs);}.entity(StructuredRiskAnswer.class)会根据 Java 类型约束模型输出并把结果反序列化为对象。但结构化输出不是传统业务代码不能默认模型每次都完全遵守约束。因此我又增加了一层确定性的 Java 归一化。三、模型输出之后还要做 Java 归一化风险等级只允许四个值privateStringnormalizeRiskLevel(StringriskLevel){if(riskLevelnull){returnUNKNOWN;}StringvalueriskLevel.trim().toUpperCase();returnswitch(value){caseHIGH,MEDIUM,LOW,UNKNOWN-value;default-UNKNOWN;};}置信度强制限制在0~1privateDoublenormalizeConfidence(Doubleconfidence){if(confidencenull){return0.0;}if(confidence0){return0.0;}if(confidence1){return1.0;}returnconfidence;}对于UNKNOWN风险再增加一条业务约束if(UNKNOWN.equals(riskLevel)confidence0.5){confidence0.5;}这一步让我更清楚地理解了 AI 系统的分工大模型负责理解和生成 Java 负责最终的数据边界和确定性校验Prompt 是软约束Java 校验才是硬约束。四、补一个流式输出接口除了结构化接口我还尝试了 Spring AI 的流式调用GetMapping(value/api/v1/stream/chat,producesMediaType.TEXT_EVENT_STREAM_VALUE)publicFluxStringstreamChat(Stringmessage){returnchatClient.prompt().user(message).stream().content();}同步调用使用.call().content()流式调用使用.stream().content()接口返回FluxString并通过text/event-stream持续向客户端发送内容。可以用下面的命令测试curl-Nhttp://localhost:8080/api/v1/stream/chat?message请用三点说明企业AI数字员工能做什么-N表示关闭 curl 的输出缓冲这样可以直接看到内容逐段返回。流式输出主要改善用户等待体验但它并不会减少模型总耗时也不能直接替代结构化输出。聊天界面适合流式返回后端确定性流程更适合结构化对象。五、Python 为什么不只是“调用一下 Java 接口”前一章里的 Python 主要负责清洗现场日志。这一章开始让 Python 承担另一个更通用的角色作为 Java AI 接口的自动验收端。Python 项目新增了一个evals目录src/ai_agent/evals/ ├── eval_cases.jsonl ├── run_java_api_eval_model.py ├── test_java_api_eval_model.py └── eval_report_model.md用例、执行逻辑、单元测试和报告被拆开管理。六、用 JSONL 保存评测用例eval_cases.jsonl每一行都是一个独立 JSON 对象{name:RAG问答-A01X温度超限,tag:rag,priority:P0,description:验证RAG能根据知识库回答温度超限处置流程,path:/api/v1/dispatch/rag-chat,params:{message:A-01X 站特级调压阀温度超过 50 度应该怎么处理},expected_keywords:[红色应急预案,关闭,疏散]}JSONL 比一个巨大的 JSON 数组更适合逐行追加也便于以后按行处理。读取时要先把字符串解析成字典datajson.loads(line)cases.append(EvalCase.model_validate(data))这里曾经踩过一个坑EvalCase.model_validate(line)line仍然是字符串而model_validate()需要字典或EvalCase对象因此会报Input should be a valid dictionary or instance of EvalCase正确顺序是JSON 字符串 - json.loads() - Python dict - Pydantic model_validate()七、用 Pydantic 定义用例和结果classEvalCase(BaseModel):name:strpath:strparams:dictField(default_factorydict)expected_keywords:list[str]Field(default_factorylist)tag:strdefaultpriority:strP1description:strdict表示 Python 字典也就是键值对集合{message:A-01X 温度超过 50 度怎么办,topK:2}执行结果也定义成 Pydantic 对象classEvalResult(BaseModel):name:strpath:strtag:strpriority:strdescription:strpassed:boolmissing_keywords:list[str]Field(default_factorylist)answer:strelapsed_ms:float0这样后续生成报告时不需要在多个松散字典之间猜字段名。八、用 httpx 调用 Java 接口defcall_get(path:str,params:dict|NoneNone)-str:urlf{BASE_URL}{path}resulthttpx.get(url,paramsparams,timeout60)result.raise_for_status()returnresult.textparams会被 httpx 转换成 URL 查询参数。raise_for_status()的作用是检查 HTTP 状态码2xx继续执行4xx抛出客户端错误5xx抛出服务端错误。如果不调用它接口返回 500 时程序可能把错误页当作普通模型答案继续评测。同时记录接口耗时defcall_api(case:EvalCase)-tuple[str,float]:start_timeperf_counter()answercall_get(case.path,case.params)elapsed_ms(perf_counter()-start_time)*1000returnanswer,elapsed_ms九、关键词验收是怎么工作的当前项目先使用最轻量的关键词检查defcheck_keywords(answer:str,expected_keywords:list[str])-tuple[bool,list[str]]:missing_keywords[]forkeywordinexpected_keywords:ifkeywordnotinanswer:missing_keywords.append(keyword)passedlen(missing_keywords)0returnpassed,missing_keywordsmissing_keywords.append(keyword)记录的是“缺失的关键词”。例如期望[红色应急预案,关闭,疏散]实际答案只有需要启动红色应急预案并关闭上游闸门那么疏散不在答案中就把它加入失败原因missing_keywords[疏散]这不是把错误内容追加到答案里而是在收集“为什么失败”。关键词评测很简单但很适合当前阶段验证关键事实有没有丢失。它的局限也很明显同义词可能被误判关键词都出现不代表答案逻辑正确不能判断引用是否真实不能评价回答是否完整。后续可以继续增加 JSON 字段断言、规则评分、语义相似度和 LLM-as-a-Judge但当前阶段先把可重复执行的闭环跑通。十、自动生成 Markdown 报告报告生成逻辑本质上是把结果逐行加入lineslines[]lines.append(# AI 接口自动验收报告)lines.append()lines.append(| 指标 | 数值 |)lines.append(|---|---:|)lines.append(f| 总用例数 |{total_count}|)lines.append(f| 通过数量 |{passed_count}|)lines.append(f| 失败数量 |{failed_count}|)lines.append(f| 通过率 |{pass_rate:.1f}% |)失败用例单独输出failed_results[resultforresultinresultsifnotresult.passed]iffailed_results:lines.append(## 失败用例)forresultinfailed_results:missing, .join(result.missing_keywords)lines.append(f|{result.name}|{missing}|)if failed_results:的意思是列表非空时才生成失败章节。全部通过时报告里就不出现空的失败表格。最后return\n.join(lines)把字符串列表拼成完整 Markdown。十一、用 pytest 固化评测器本身AI 接口需要评测评测代码本身也需要测试。pytest.mark.parametrize(answer, expected_keywords, expected_passed, expected_missing,[(需要启动红色应急预案并关闭上游闸门,[红色应急预案,关闭],True,[],),(需要启动红色应急预案,[红色应急预案,关闭],False,[关闭],),],)deftest_check_keywords(answer,expected_keywords,expected_passed,expected_missing):passed,missing_keywordscheck_keywords(answer,expected_keywords)assertpassedisexpected_passedassertmissing_keywordsexpected_missingpytest.mark.parametrize可以让同一个测试函数运行多组数据。assert表示断言实际结果不满足条件时测试立即失败并指出位置。执行pytest-qsrc/ai_agent/evals/test_java_api_eval_model.py十二、完整运行顺序先启动 Java 项目然后运行 Python/Users/wangju/PycharmProjects/ai-agent/.venv/bin/python\/Users/wangju/PycharmProjects/ai-agent/src/ai_agent/evals/run_java_api_eval_model.py脚本会调用/api/admin/ingest构建 RAG 知识库从 JSONL 读取用例逐条调用 Java AI 接口检查预期关键词统计通过率和耗时生成eval_report_model.md。当前一次本地运行结果是总用例数3 通过数量3 失败数量0 通过率100.0%十三、这一章最重要的收获这次实践让我把几个概念串了起来Prompt 约束模型行为 Java DTO 约束返回结构 Java 归一化保证确定性边界 Python JSONL 保存可重复用例 Pydantic 校验输入和结果 pytest 保证评测器逻辑不退化 Markdown 报告沉淀每次验收结果AI 工程化并不是一开始就搭一个庞大的评测平台。先从三条真实用例、一个 Python 脚本和一份 Markdown 报告开始就已经比只在 Swagger 里手工点接口更容易发现回归。下一步会继续深入 Spring AI 的 Advisor 调用链并把权限校验从 Prompt 软约束推进到工具方法内部的硬边界。