19. 从 AI 客服项目看 Harness Engineering:当 Agent 不再只是 Prompt

本文基于 ai-customer-service-lab 项目源码分析,结合 2026 年业界对 Harness Engineering 的讨论,探讨如何把「套在模型外面的工程体系」做成可复现、可度量、可演进的一等公民。


一、2026 年的范式转移:从 Prompt 到 Harness

如果你做过两年以上的 LLM 应用,大概会经历这样的演进路径:

  1. Prompt Engineering(2022--2024):写好系统提示词,调 temperature,祈祷模型听话。
  2. Context Engineering(2025):RAG、Memory、Tool Calling 堆上去,上下文越来越长。
  3. Harness Engineering(2026):问题不再是「怎么写一句更好的 prompt」,而是「怎么设计一整套运行环境,让 Agent 持续、可靠、可审计地工作」。

Martin Fowler 的 Harness Engineering 文章 用了一个很贴切的比喻:Agent = 模型 + Harness(挽具/外骨骼)。外骨骼做两件事:

  • 前馈控制(Guides):在执行前引导 Agent 走对路------Prompt 模板、路由规则、工具白名单、权限边界。
  • 反馈控制(Sensors):在执行后观测结果------评估打分、Trace 快照、关键词命中、人工审核。

两者缺一不可。只有前馈,你不知道引导是否真的有效;只有反馈,Agent 会反复犯同样的错。

OpenAI、Anthropic 在 2026 年初密集发文,核心共识是:模型的非确定性不会消失,但 Harness 可以把不确定性关进可控的管道里。Anthropic 甚至发现,让模型自我评估往往过于宽松,分离出独立的 Evaluator Agent 才是成熟 Harness 的标志。

这个实验室项目,恰好把这套思想落到了代码里。

二、项目全景:一个为 Harness 而生的 AI 客服实验室

ai-customer-service-lab 不是一个「能聊天的 Demo」,而是一个 能力演进 + 评估对比 的实验场:

层级 技术 职责
后端 Java 17 + Spring Boot 3.3,14 个 Maven 模块 模块化编排、评估 Harness、双端口部署
前端 React 19 + Vite + Zustand 聊天调试、五档能力对比、Agent Trace 可视化
LLM LangChain4j(OpenAI 兼容 API) 生产路径;评估路径用 Stub 替代

模块依赖自底向上清晰分层:

复制代码
ai-common (SPI 契约)
  → ai-core / ai-prompt / ai-rag / ai-memory / ai-tools / ai-agent-router
    → ai-service (唯一编排入口)
      → ai-eval (评估 Harness)
        → ai-reactive-chat (8081) | ai-admin-webmvc (8080)

关键设计决策:所有路径都走同一个 AiChatService。生产环境调真实 LLM,评估环境换 Stub LLM------管道不变,变量可控。这正是 Harness-Bench 等学术基准所强调的「隔离 Harness 效应」思路。

三、编排管道:Harness 的「办公室」

AiChatService 是整个系统的唯一编排入口,注释写得很直白:

18:23:ai-customer-service/ai-service/src/main/java/com/aics/service/chat/AiChatService.java 复制代码
/**
 * 系统唯一编排入口:Agent 路由决策 → 聚合 memory / RAG / tools,经 prompt 构建后调用 LLM,不写具体领域实现。
 * <p>
 * 流程:load memory → {@link AgentRouter} →(可选)RAG →(可选)tools → build prompt → LLM → save memory。<br>
 * 路由可由 LLM(JSON)或规则实现,见 {@code aics.orchestration.agent-router-llm-enabled}。
 */

每一轮对话的执行链路:

复制代码
loadHistory(sessionId)
  → agentRouter.route(message, history)        // 决策:要不要 RAG?调哪个 Tool?
  → [optional] rag.retrieve(message)           // 双门控:全局开关 AND 路由决策
  → [optional] tools.executeNamed(toolName, message)
  → promptComposer.build(history, context, toolResult, message)
  → llm.chat(prompt)
  → memory.saveMessage(sessionId, message, answer)
  → ChatTurnTraceResult(含完整快照)

这里有一个容易被忽略但非常重要的 双门控(Product Switch × Agent Decision) 模式:

70:76:ai-customer-service/ai-service/src/main/java/com/aics/service/chat/AiChatService.java 复制代码
        boolean useRag = orchestrationProperties.isRagEnabled() && decision.useRag();
        List<String> context = useRag ? rag.retrieve(message) : Collections.emptyList();

        boolean useTools = orchestrationProperties.isToolsEnabled() && decision.useTools();
        String toolResult = useTools
                ? tools.executeNamed(decision.toolName(), message)
                : "";

全局开关和 Agent 决策做 AND 组合------运维可以一键降级 RAG,而 Agent Router 仍然可以精细决策。这是典型的 前馈控制分层:产品级开关在上,语义级路由在下。

chatWithTrace() 返回的 ChatTurnTraceResult 不是日志旁路,而是 API 响应的一等字段。开发模式下,前端可以直接展开「Agent 编排调试」面板,看到决策 → RAG 片段 → 工具结果 → 完整 Prompt 的完整链路。这就是 在线 Sensor

四、Agent Router:Harness 里的「调度员」

在 FULL 能力档位下,系统多了一层 元决策:不是直接回答问题,而是先决定「这轮需要哪些能力」。

Router 的 Prompt 强制 JSON 输出,键名固定、工具白名单枚举:

8:24:ai-customer-service/ai-agent-router/src/main/java/com/aics/agentrouter/prompt/AgentRouterPrompts.java 复制代码
    private static final String TEMPLATE = """
            你是一个 AI 客服系统的「能力路由决策器」。根据用户当前问题(必要时结合历史摘要),判断本轮是否需要:
            1)检索知识库(RAG)
            2)调用业务工具(Tools)

            可用工具名称(toolName)仅限以下之一;若不需要工具则 useTools 为 false,toolName 为空字符串 "":
            - echo:用户明确要求回显或调试,或消息以 echo: 开头
            - order_query:订单、物流、发货、ORD- 单号等相关
            - weather_query:天气、气温、降雨等
            ...
            只输出一个 JSON 对象,不要 Markdown 代码块,不要其它解释文字。键名必须完全一致:
            {"useRag":true或false,"useTools":true或false,"toolName":"字符串或空","reason":"一句话中文理由"}

生产环境有三种 Router 实现,形成 Graceful Degradation 链:

实现 机制 场景
LlmAgentRouter 额外一次 LLM 调用,解析 JSON 决策 默认开启
RuleBasedAgentRouter 启发式关键词匹配 LLM 关闭或降级
FallbackAgentRouter LLM 失败时自动回退规则路由 生产容错

Router 本身也是 Harness 的一部分------它限制了模型能「看到」哪些工具、能「触发」哪些能力。没有白名单,Agent 可能幻觉出一个不存在的 refund_tool

五、五档能力模型:可度量的演进阶梯

项目用 AiVersion 枚举定义了五个能力档位,用于横向对比:

6:12:ai-customer-service/ai-eval/src/main/java/com/aics/eval/AiVersion.java 复制代码
public enum AiVersion {
    BASE,
    PROMPT,
    RAG,
    MEMORY,
    FULL
}
档位 启用能力 模拟场景
BASE 裸 LLM,用户问题直接透传 「仓库可能比较忙吧,我也不确定」
PROMPT + 客服角色与段落结构 有规范,但无业务数据
RAG + 知识库检索 能引用发货时效政策
MEMORY + 预置会话历史 记得用户已付款
FULL + RAG + Tools 订单 123 拣货中,华东仓已揽收

这不是随意分的层------每一层解决一个明确的失败模式:BASE 会幻觉,PROMPT 会泛化,RAG 缺实时数据,MEMORY 缺工具精度,FULL 才是生产目标态。

六、评估 Harness 的核心:CapabilityChatFactory + RecordingLlmClient

这是整篇文章最值得细读的部分。ai-eval 模块构成了一套完整的 离线评估 Harness,代码里没有出现 "harness" 这个词,但模式非常标准。

6.1 确定性装配:CapabilityChatFactory

37:84:ai-customer-service/ai-eval/src/main/java/com/aics/eval/support/CapabilityChatFactory.java 复制代码
    public static AiChatService build(AiVersion version, RecordingLlmClient llm) {
        return switch (version) {
            case BASE -> new AiChatService(
                    new EvalChatMemory(""),
                    q -> List.of(),
                    m -> "",
                    new PassthroughPromptComposer(),
                    llm,
                    new FixedAgentRouter(AgentDecision.none()),
                    EVAL_ORCH
            );
            // ... PROMPT, RAG, MEMORY, FULL 各档装配
        };
    }

三个 Harness 设计要点:

  1. 无 Spring 手动装配:与生产代码同路径,但脱离容器依赖,可在 CLI / 单测 / CI 中直接运行。
  2. FixedAgentRouter 消除路由随机性 :评估时固定 AgentDecision,隔离「路由不确定性」,专注对比 Prompt/RAG/Memory/Tool 的贡献。这是 Harness Engineering 的关键模式------控制变量,只测你想测的
  3. 内置 Golden DatasetKB 常量(发货时效说明)和 TOOL_JSON 常量(订单 123 状态)即最小 golden set,保证每次评估输入一致。

6.2 Prompt-as-Spec 假 LLM:RecordingLlmClient

15:33:ai-customer-service/ai-eval/src/main/java/com/aics/eval/support/RecordingLlmClient.java 复制代码
    public static String fakeAnswer(String prompt) {
        boolean sys = prompt.contains("你是专业 AI 客服助手");
        boolean rag = prompt.contains("### 参考知识");
        boolean tool = prompt.contains("### 工具结果");
        boolean mem = prompt.contains("### 历史对话");
        if (sys && rag && tool && mem) {
            return "【FULL】订单123当前状态已同步:发货环节进行中;...";
        }
        if (sys && rag && mem && !tool) {
            return "【MEMORY+RAG】根据历史与知识:...";
        }
        // ...
        return "【BASE】大概还没发吧,我也不确定订单和物流细节。";
    }

这个 Stub 的精妙之处在于:它不模拟模型的「智能」,而是模拟「Prompt 结构是否正确传递到了模型」 。检测 Prompt 中是否包含 ### 参考知识### 工具结果 等段落标记,返回对应档位的预设答案。

收益显而易见:

  • 零 API 成本:不调用真实模型,CI 秒级完成。
  • 完全可重复:同样输入,永远同样输出。
  • 可断言 :进化测试直接 assertThat(llm.lastPrompt).contains("### 参考知识")

这正是 VeRO(Versioning, Rewards, and Observations)论文所倡导的:对 Agent 这种「确定性代码 + 随机 LLM」混合系统,需要结构化捕获中间推理和执行结果。

6.3 评估闭环:EvaluationRunner

15:26:ai-customer-service/ai-eval/src/main/java/com/aics/eval/EvaluationRunner.java 复制代码
    public EvaluationReport run(EvaluationCase evaluationCase) {
        Map<AiVersion, VersionEvaluation> map = new LinkedHashMap<>();
        for (AiVersion v : AiVersion.values()) {
            RecordingLlmClient llm = new RecordingLlmClient();
            AiChatService svc = CapabilityChatFactory.build(v, llm);
            String answer = svc.chat("eval-session-" + v.name(), evaluationCase.question());
            int score = AiEvaluator.score(answer, evaluationCase);
            String explanation = AiEvaluator.explain(answer, evaluationCase);
            map.put(v, new VersionEvaluation(v, answer, score, explanation));
        }
        return new EvaluationReport(evaluationCase, map);
    }

对每个 EvaluationCase(问题 + 期望关键词 + 分类),在全部五个版本上跑一遍,汇总评分,并计算相对 BASE 的分差 deltaFromBase()EvaluationReport.toMarkdownTable() 可以直接生成对比表------写博客时复制粘贴即可。

评分策略有意保持简单:关键词命中率,而非 LLM-as-Judge。教学场景下可解释、零依赖、可手工验证;注释里预留了扩展空间。

七、进化测试:Harness 即活文档

AiServiceEvolutionTest 四个 @Order 有序的测试,用同一个问题「我的订单 123 为什么还没有发货?」,逐步叠加能力:

48:133:ai-customer-service/ai-service/src/test/java/com/aics/service/evolution/AiServiceEvolutionTest.java 复制代码
    @Test @Order(1) void testBaseLLM() { ... assertThat(answer).contains("纯模型"); }
    @Test @Order(2) void testPromptEnhanced() { ... assertThat(llm.lastPrompt).contains("你是专业 AI 客服助手"); }
    @Test @Order(3) void testRagEnhanced() { ... assertThat(llm.lastPrompt).contains("### 参考知识"); }
    @Test @Order(4) void testFullAiService() { ... assertThat(llm.lastPrompt).contains("拣货中"); }

注释里写了一句大实话:

根据 prompt 中是否包含模板块,返回可读的「模拟模型答复」,便于对比阶段差异

这不是事后补的测试,而是 为教学和演示而设计的 Harness 剧本 。跑一遍 mvn test -Dtest=AiServiceEvolutionTest,控制台会打印每个阶段的模拟答复,肉眼可见能力叠加的效果。

八、双 Harness 架构:离线评估 + 在线 Trace

这个项目同时运行两套互补的 Harness:

复制代码
┌─────────────────────────────────────────────────────────┐
│                    离线 Harness (ai-eval)                │
│  CapabilityChatFactory → RecordingLlmClient → 关键词评分 │
│  入口:CLI / POST /api/eval/run / 前端 Eval 页          │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                    在线 Harness (运行时)                   │
│  AiChatService.chatWithTrace() → ChatTurnTraceResult    │
│  前端 AgentDecisionPanel / RagContextPanel / PromptPanel │
│  入口:POST /api/chat(开发模式 expose-prompt-trace)     │
└─────────────────────────────────────────────────────────┘
维度 离线 Harness 在线 Harness
LLM Stub(零成本) 真实模型
目的 能力档位对比、回归测试 单轮调试、排障
评分 关键词命中率 人工 + Trace 审查
可 CI 化 需集成测试环境

Martin Fowler 所说的「如果 Sensor 从不触发,是质量高还是检测不足?」------这个项目给出了一个务实的起点:离线用确定性 Stub 保证管道结构正确,在线用 Trace 暴露真实模型的行为。两者结合,才能建立对 Agent 系统的信任。

九、前馈 vs 反馈:一张对照表

把项目里的组件映射到 Harness Engineering 的双轴框架:

类型 组件 作用
前馈 · 计算型 DefaultPromptComposer、段落标记规范 结构化 Prompt,约束模型输入格式
前馈 · 计算型 AgentRouterPrompts、工具白名单 限制 Agent 可触达的能力边界
前馈 · 计算型 OrchestrationProperties 全局开关 产品级降级与 A/B
前馈 · 推断型 LlmAgentRouter LLM 做语义级路由决策
前馈 · 推断型 RuleBasedAgentRouter 关键词启发式兜底
反馈 · 计算型 AiEvaluator 关键词评分 确定性、可解释的 pass/fail
反馈 · 计算型 AiServiceEvolutionTest 结构断言 Prompt 是否包含预期段落
反馈 · 计算型 deltaFromBase() 量化每层能力的边际收益
反馈 · 推断型 ChatTurnTraceResult + 前端调试面板 人工审查完整决策链
反馈 · 推断型 PromptEvaluationService 环形缓冲 Prompt 构建审计日志

前馈让 Agent「第一次就做对」的概率更高;反馈让你知道「做对了没有」以及「哪一层在贡献」。

十、架构图:端到端 Harness 全景

#mermaid-svg-QAYOwDxu67YIHew1{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QAYOwDxu67YIHew1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QAYOwDxu67YIHew1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QAYOwDxu67YIHew1 .error-icon{fill:#552222;}#mermaid-svg-QAYOwDxu67YIHew1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QAYOwDxu67YIHew1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QAYOwDxu67YIHew1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QAYOwDxu67YIHew1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QAYOwDxu67YIHew1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QAYOwDxu67YIHew1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QAYOwDxu67YIHew1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QAYOwDxu67YIHew1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QAYOwDxu67YIHew1 .marker.cross{stroke:#333333;}#mermaid-svg-QAYOwDxu67YIHew1 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QAYOwDxu67YIHew1 p{margin:0;}#mermaid-svg-QAYOwDxu67YIHew1 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-QAYOwDxu67YIHew1 .cluster-label text{fill:#333;}#mermaid-svg-QAYOwDxu67YIHew1 .cluster-label span{color:#333;}#mermaid-svg-QAYOwDxu67YIHew1 .cluster-label span p{background-color:transparent;}#mermaid-svg-QAYOwDxu67YIHew1 .label text,#mermaid-svg-QAYOwDxu67YIHew1 span{fill:#333;color:#333;}#mermaid-svg-QAYOwDxu67YIHew1 .node rect,#mermaid-svg-QAYOwDxu67YIHew1 .node circle,#mermaid-svg-QAYOwDxu67YIHew1 .node ellipse,#mermaid-svg-QAYOwDxu67YIHew1 .node polygon,#mermaid-svg-QAYOwDxu67YIHew1 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-QAYOwDxu67YIHew1 .rough-node .label text,#mermaid-svg-QAYOwDxu67YIHew1 .node .label text,#mermaid-svg-QAYOwDxu67YIHew1 .image-shape .label,#mermaid-svg-QAYOwDxu67YIHew1 .icon-shape .label{text-anchor:middle;}#mermaid-svg-QAYOwDxu67YIHew1 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-QAYOwDxu67YIHew1 .rough-node .label,#mermaid-svg-QAYOwDxu67YIHew1 .node .label,#mermaid-svg-QAYOwDxu67YIHew1 .image-shape .label,#mermaid-svg-QAYOwDxu67YIHew1 .icon-shape .label{text-align:center;}#mermaid-svg-QAYOwDxu67YIHew1 .node.clickable{cursor:pointer;}#mermaid-svg-QAYOwDxu67YIHew1 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-QAYOwDxu67YIHew1 .arrowheadPath{fill:#333333;}#mermaid-svg-QAYOwDxu67YIHew1 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-QAYOwDxu67YIHew1 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-QAYOwDxu67YIHew1 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QAYOwDxu67YIHew1 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-QAYOwDxu67YIHew1 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QAYOwDxu67YIHew1 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-QAYOwDxu67YIHew1 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-QAYOwDxu67YIHew1 .cluster text{fill:#333;}#mermaid-svg-QAYOwDxu67YIHew1 .cluster span{color:#333;}#mermaid-svg-QAYOwDxu67YIHew1 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-QAYOwDxu67YIHew1 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-QAYOwDxu67YIHew1 rect.text{fill:none;stroke-width:0;}#mermaid-svg-QAYOwDxu67YIHew1 .icon-shape,#mermaid-svg-QAYOwDxu67YIHew1 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QAYOwDxu67YIHew1 .icon-shape p,#mermaid-svg-QAYOwDxu67YIHew1 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-QAYOwDxu67YIHew1 .icon-shape .label rect,#mermaid-svg-QAYOwDxu67YIHew1 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QAYOwDxu67YIHew1 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-QAYOwDxu67YIHew1 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-QAYOwDxu67YIHew1 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 反馈控制 Sensors
编排核心
前馈控制 Guides
PromptComposer

段落结构规范
AgentRouter

能力路由决策
OrchestrationProperties

全局开关
Tool白名单

echo/order/weather
AiChatService

唯一管道
EvaluationRunner

五档横向对比
RecordingLlmClient

Prompt结构检测
ChatTurnTraceResult

运行时快照
AiServiceEvolutionTest

进化剧本
用户

十一、还能学什么?Harness 的下一步

这个项目已经搭好了 Harness 的骨架,但距离生产级 Harness 还有清晰的演进方向------也正是博客读者可以动手实验的地方:

  1. Evaluator 升级:从关键词命中到 LLM-as-Judge(需独立 Evaluator Agent,避免 Generator 自我放水),或引入 Playwright 端到端验证。
  2. Golden Dataset 扩展DemoData.ORDER_SHIPPING 只是单 case,可扩展为分类数据集(订单、退款、寒暄、越权),形成回归测试集。
  3. Harness 覆盖率:借鉴代码覆盖率思路------哪些意图类别有 golden case?哪些 Tool 路径从未被评估触及?
  4. 模型无关性RecordingLlmClient 已证明管道与模型解耦;下一步是在同一 Harness 上切换 GPT / Claude / 本地模型,量化 Harness 效应(Harness-Bench 的核心问题)。
  5. Sensor 告警PromptEvaluationService 的内存环形缓冲可对接 OLAP,当 Prompt 结构异常(如 RAG 段落缺失)时自动告警。

十二、结语:Agent 的时代,工程师造的是 Harness

回到开头的问题。2026 年做 AI 应用,核心竞争力不再是「谁能写出最炫的 system prompt」,而是:

  • 能否把 编排管道 做成单一、可测试、可 Trace 的入口?
  • 能否用 确定性 Harness 隔离变量,量化每一层能力的边际收益?
  • 能否同时运行 离线评估在线观测,建立对非确定性系统的工程信任?

ai-customer-service-lab 用不到 14 个 Maven 模块、一个 RecordingLlmClient、五档 AiVersion,给出了一份可运行的答案。代码里没有「harness」这个词,但 CapabilityChatFactoryFixedAgentRouterAiServiceEvolutionTestChatTurnTraceResult 合在一起,就是 Harness Engineering 的最佳实践注解。

模型会升级,Prompt 会过时,但 Harness 是留得住的工程资产。

如果你想亲手复现,可以:

  1. 跑进化测试:mvn test -Dtest=AiServiceEvolutionTest(需关闭 skipTests
  2. 跑评估 CLI:mvn exec:java -pl ai-eval
  3. 启动双端口后打开前端 /eval 页,五列卡片直观对比 BASE → FULL