AI Agent 如何接入真实业务系统?订单助手三层架构完整拆解

AI Agent 如何接入真实业务系统?订单助手三层架构完整拆解

项目:Order Agent Assistant ------ 一个「用自然语言管理订单」的 AI Agent 作品 仓库:https://github.com/maixiaowen111/order-agent-assistant 前端、决策层、执行层三层齐全,Docker 一键起,clone 下来就能跑。

⚠️ 如实说明 :支付/退款是状态模拟 + 事件通知,不接真实支付通道。这一点在项目 README 里也是这么写的------面试时主动讲清楚边界,比被追问出来强。

一、业务背景:Agent 的直接接法是错的

大二大三做 AI 项目,最常见的写法是这样:把大模型 API 一调,模型说"取消订单",代码就 UPDATE t_order SET status='CANCELLED'。

这个写法能跑通 Demo,但在企业里活不过第一天。原因是三条硬约束:

  1. 模型是概率系统,不是确定性系统 。同一句话,这次它调 cancel_order,下次它可能调 get_order。你无法用提示词 100% 约束它(这个坑我真实踩过,见第五节第 4 条)。
  2. 业务规则不能有两份 。订单状态机、库存恢复、退款事件,这些逻辑已经在 order-system 里了。如果 Agent 也写一遍,两份规则迟早漂移------改了一边忘了另一边,就是线上事故。
  3. 写操作不能由模型单方面决定。让一个概率系统自主执行"取消订单并退款",等于把资损风险交给随机性。

所以正确的架构不是"Agent 直连数据库",而是:

模型负责决策(选哪个工具、怎么组织答案),代码负责执行和搬运。业务副作用只在业务系统里发生一次。

这句话是整个项目的设计纲领。下面全部展开。

下面是整体架构的 Mermaid 图,把「模型决策、代码执行、副作用归口」的边界画清楚:
#mermaid-svg-G5gyPJZ4v8IjXIv8{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-G5gyPJZ4v8IjXIv8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .error-icon{fill:#552222;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .marker.cross{stroke:#333333;}#mermaid-svg-G5gyPJZ4v8IjXIv8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-G5gyPJZ4v8IjXIv8 p{margin:0;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .cluster-label text{fill:#333;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .cluster-label span{color:#333;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .cluster-label span p{background-color:transparent;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .label text,#mermaid-svg-G5gyPJZ4v8IjXIv8 span{fill:#333;color:#333;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .node rect,#mermaid-svg-G5gyPJZ4v8IjXIv8 .node circle,#mermaid-svg-G5gyPJZ4v8IjXIv8 .node ellipse,#mermaid-svg-G5gyPJZ4v8IjXIv8 .node polygon,#mermaid-svg-G5gyPJZ4v8IjXIv8 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .rough-node .label text,#mermaid-svg-G5gyPJZ4v8IjXIv8 .node .label text,#mermaid-svg-G5gyPJZ4v8IjXIv8 .image-shape .label,#mermaid-svg-G5gyPJZ4v8IjXIv8 .icon-shape .label{text-anchor:middle;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .rough-node .label,#mermaid-svg-G5gyPJZ4v8IjXIv8 .node .label,#mermaid-svg-G5gyPJZ4v8IjXIv8 .image-shape .label,#mermaid-svg-G5gyPJZ4v8IjXIv8 .icon-shape .label{text-align:center;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .node.clickable{cursor:pointer;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .arrowheadPath{fill:#333333;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-G5gyPJZ4v8IjXIv8 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-G5gyPJZ4v8IjXIv8 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-G5gyPJZ4v8IjXIv8 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .cluster text{fill:#333;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .cluster span{color:#333;}#mermaid-svg-G5gyPJZ4v8IjXIv8 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-G5gyPJZ4v8IjXIv8 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-G5gyPJZ4v8IjXIv8 rect.text{fill:none;stroke-width:0;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .icon-shape,#mermaid-svg-G5gyPJZ4v8IjXIv8 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .icon-shape p,#mermaid-svg-G5gyPJZ4v8IjXIv8 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .icon-shape .label rect,#mermaid-svg-G5gyPJZ4v8IjXIv8 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-G5gyPJZ4v8IjXIv8 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-G5gyPJZ4v8IjXIv8 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-G5gyPJZ4v8IjXIv8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 存储
执行层 order-system :8080
决策层 order-agent-assistant :8081
客户端层
/query /approve
模型调用 DeepSeek
工具结果
写操作需批准
批准后放行
内部密钥 HTTP
同事务写事件
异步补发
多轮记忆
复用同一套工具+闸门
Vue3 电商页 + 聊天面板
AgentLoop(ReAct 循环)
WritePermissionGate 权限闸门
Redis 会话记忆(TTL 30min)
MCP 兼容层 /mcp
订单状态机(幂等取消)
Transactional Outbox
通知中心(退款通知)
MySQL order_db
Redis
DeepSeek 工具调用

二、三层架构与完整数据流

bash 复制代码
┌───────────────────────────────────────────────────────────────────┐
│  客户端:浏览器(http://localhost:8082)                             │
│    Vue3 电商页 + 右下角 AI 聊天面板(批准按钮在气泡里)               │
└──────────────┬────────────────────────────────────────────────────┘
               │ /api/**(商品/购物车/订单/通知)    /query /approve
               ▼                                        ▼
┌──────────────────────────────┐      ┌───────────────────────────────┐
│  order-system  业务执行层 8080 │      │  order-agent-assistant 决策层 │
│  · 订单状态机(幂等取消)       │◄────►│  8081  AgentLoop: 模型=决策者  │
│  · Transactional Outbox       │HTTP  │  · 权限闸门:写操作人工批准     │
│  · 通知中心(退款通知)         │内部  │  · Redis 多轮记忆(TTL 30min) │
│  · 管理员商品管理              │密钥  │  · DeepSeek 工具调用           │
│                              │      │  · MCP 兼容层(/mcp 协议)       │
└──────────┬────────────────────┘      └──────────────┬────────────────┘
           ▼                                          ▼
      MySQL (order_db)                          Redis(会话记忆)

为什么是这个切分?

层 唯一职责 不做什么
前端 Vue3 电商体验 + 聊天面板 + 批准按钮 不判断业务规则
决策层 Agent 模型调用、工具编排、对话记忆、权限闸门 不直连数据库,不写业务规则
执行层 order-system 状态机、库存、Outbox、通知中心 不关心调用方是人还是 AI

关键在于执行层对调用方无感 :同一个"取消订单"接口,前端点按钮调它,Agent 调它,走的是同一套状态机、同一个库存恢复逻辑、同一个 REFUND 事件。AI 不是一条特殊通道,只是另一个客户端。

服务之间用内部密钥鉴权(不是用户 JWT)------Agent 是服务端到服务端的调用方,拿的是服务凭证,不是某个用户的身份。

核心闭环的数据流

bash 复制代码
用户: "帮我取消订单 2026..."
   │
   ├─ ① 前端 POST /query  → 决策层 AgentLoop
   │
   ├─ ② AgentLoop → DeepSeek(带上工具定义)
   │       模型决策:调用 cancel_order(orderNo="2026...")
   │
   ├─ ③ 权限闸门拦截:cancel_order 是【写操作】,未批准
   │       → 不执行!转述:"需要人工确认后才能执行"
   │
   ├─ ④ 前端聊天气泡弹出「批准执行」按钮
   │
   ├─ ⑤ 点击 → POST /approve → 闸门放行
   │
   ├─ ⑥ AgentLoop 再次驱动模型执行 cancel_order
   │       → 内部密钥调用 order-system 8080
   │
   ├─ ⑦ order-system:状态机置 CANCELLED
   │       + 恢复库存 + 插 REFUND 事件(Outbox,同事务)
   │
   └─ ⑧ 事件被补发消费 → 通知中心生成真实退款通知
          前端刷新订单 → 用户看到「已取消」+ 一条退款通知

注意第 ③ 步:闸门拦的不是"模型调错了工具",而是"这是一个写操作"。 这是通用机制,不是针对某个工具的硬编码------这一点背后有个真实的坑(第五节第 10 条)。

把上面的数据流画成时序图,能更清楚地看到「闸门拦截 → 人工批准 → 执行」的先后顺序:
order-system 权限闸门 DeepSeek AgentLoop 前端 Vue3 用户 order-system 权限闸门 DeepSeek AgentLoop 前端 Vue3 用户 #mermaid-svg-KnL2vIVLjL0f6Wrd{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-KnL2vIVLjL0f6Wrd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KnL2vIVLjL0f6Wrd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KnL2vIVLjL0f6Wrd .error-icon{fill:#552222;}#mermaid-svg-KnL2vIVLjL0f6Wrd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KnL2vIVLjL0f6Wrd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KnL2vIVLjL0f6Wrd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KnL2vIVLjL0f6Wrd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KnL2vIVLjL0f6Wrd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KnL2vIVLjL0f6Wrd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KnL2vIVLjL0f6Wrd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KnL2vIVLjL0f6Wrd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KnL2vIVLjL0f6Wrd .marker.cross{stroke:#333333;}#mermaid-svg-KnL2vIVLjL0f6Wrd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KnL2vIVLjL0f6Wrd p{margin:0;}#mermaid-svg-KnL2vIVLjL0f6Wrd .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-KnL2vIVLjL0f6Wrd text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-KnL2vIVLjL0f6Wrd .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-KnL2vIVLjL0f6Wrd .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-KnL2vIVLjL0f6Wrd .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-KnL2vIVLjL0f6Wrd .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-KnL2vIVLjL0f6Wrd #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-KnL2vIVLjL0f6Wrd .sequenceNumber{fill:white;}#mermaid-svg-KnL2vIVLjL0f6Wrd #sequencenumber{fill:#333;}#mermaid-svg-KnL2vIVLjL0f6Wrd #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-KnL2vIVLjL0f6Wrd .messageText{fill:#333;stroke:none;}#mermaid-svg-KnL2vIVLjL0f6Wrd .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-KnL2vIVLjL0f6Wrd .labelText,#mermaid-svg-KnL2vIVLjL0f6Wrd .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-KnL2vIVLjL0f6Wrd .loopText,#mermaid-svg-KnL2vIVLjL0f6Wrd .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-KnL2vIVLjL0f6Wrd .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-KnL2vIVLjL0f6Wrd .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-KnL2vIVLjL0f6Wrd .noteText,#mermaid-svg-KnL2vIVLjL0f6Wrd .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-KnL2vIVLjL0f6Wrd .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-KnL2vIVLjL0f6Wrd .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-KnL2vIVLjL0f6Wrd .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-KnL2vIVLjL0f6Wrd .actorPopupMenu{position:absolute;}#mermaid-svg-KnL2vIVLjL0f6Wrd .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-KnL2vIVLjL0f6Wrd .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-KnL2vIVLjL0f6Wrd .actor-man circle,#mermaid-svg-KnL2vIVLjL0f6Wrd line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-KnL2vIVLjL0f6Wrd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} "帮我取消订单 2026..." POST /query 带上工具定义 调用 cancel_order(orderNo) 校验是否已批准 未批准(写操作) 需要人工确认 气泡弹出「批准执行」按钮 点击批准 POST /approve 标记已批准(指纹) 再次驱动执行 执行 cancel_order 内部密钥调用 状态机置 CANCELLED + 恢复库存 + 插 REFUND 事件(同事务) 成功 结果 显示「已取消」+ 退款通知

三、决策层实现:AgentLoop

AgentLoop 是一个标准的 ReAct 风格循环,但每一处都加了企业级的护栏。

java 复制代码
@Component
@RequiredArgsConstructor
@Slf4j
public class AgentLoop {

    /** 最大执行步数:每次模型调用、每次工具执行都算一步 */
    private static final int MAX_STEPS = 8;

    private final ModelClient modelClient;          // DeepSeek 封装
    private final ToolRegistry toolRegistry;        // 工具注册表
    private final WritePermissionGate gate;         // 写操作闸门
    private final SessionStore sessionStore;        // Redis 多轮记忆(TTL 30min)
    private final LogSanitizer sanitizer;           // 日志脱敏

    public AgentResponse run(String sessionId, String userInput) {
        long start = System.currentTimeMillis();
        List<Message> messages = sessionStore.load(sessionId);   // 载入多轮记忆
        messages.add(Message.user(sanitizer.sanitizeText(userInput)));

        int step = 0;
        while (step++ < MAX_STEPS) {
            ChatResponse resp = modelClient.chat(messages, toolRegistry.definitions());

            // A. 模型要调工具
            if (resp.hasToolCalls()) {
                messages.add(Message.assistant(resp.toolCalls()));
                for (ToolCall call : resp.toolCalls()) {
                    ToolResult result = executeTool(sessionId, call);
                    messages.add(Message.tool(call.id(), result.toJson()));
                    logStep(sessionId, step, call.name(), result, start);
                }
                if (resp.anyNeedsApproval()) {
                    // 撞到闸门就立刻停,把"待批准"交给前端
                    sessionStore.save(sessionId, messages);
                    return AgentResponse.needApproval(resp.approvalPrompt());
                }
                continue;
            }

            // B. 模型给出最终答案
            sessionStore.save(sessionId, messages);
            return AgentResponse.ok(resp.content(), step);
        }

        // C. 撞上限:给用户一句能看懂的话,不是抛栈
        sessionStore.save(sessionId, messages);
        log.warn("Agent 达到最大步数 sessionId={}", sessionId);
        return AgentResponse.limit("这个问题需要的步骤太多,请拆成更具体的问题再问我。");
    }

    /** 统一工具执行出口:闸门 → 执行 → 异常转换 */
    private ToolResult executeTool(String sessionId, ToolCall call) {
        Tool tool = toolRegistry.get(call.name());
        if (tool == null) {
            return ToolResult.fail("UNKNOWN_TOOL", "不支持的操作:" + call.name());
        }

        // ① 权限闸门:只读放行,写操作必须人工批准
        if (tool.isWrite() && !gate.isApproved(sessionId, tool.name(), call.arguments())) {
            gate.markPending(sessionId, tool.name(), call.arguments());
            return ToolResult.fail("NEED_APPROVAL",
                    "这是写操作,需要你确认后才能执行。");
        }

        // ② 执行 + 异常转换:绝不把 Java 堆栈喂给模型
        try {
            return tool.execute(call.arguments());
        } catch (TimeoutException e) {
            return ToolResult.fail("TIMEOUT", "下游服务响应超时,请稍后重试。");
        } catch (FeignException e) {
            return ToolResult.fail("UPSTREAM_" + e.status(), "业务系统返回异常状态。");
        } catch (JsonProcessingException e) {
            return ToolResult.fail("BAD_RESPONSE", "下游返回内容无法解析。");
        } catch (BusinessException e) {
            return ToolResult.fail(e.getErrorCode(), e.getMessage());  // 业务拒绝,属于正常分支
        } catch (Exception e) {
            log.error("工具执行未知异常 tool={}", call.name(), e);
            return ToolResult.fail("INTERNAL_ERROR", "执行失败,请稍后重试。");
        }
    }
}

几个必须说清楚的设计点:

  • MAX_STEPS = 8 是硬上限。模型可能陷入"调工具 → 看结果 → 再调同一个工具"的死循环。8 步到顶立刻停,返回一句人话,而不是让请求挂在那里烧 token。每次模型调用、每次工具执行都计入,不是只算工具。
  • 闸门在 executeTool 的统一出口,不在每个 Tool 内部。这样新增工具不需要记得加校验,漏不掉。
  • 异常全部转成 {"success":false,"errorCode":...,"message":...} 。模型看到的永远是一个干净的结构化结果,它可以根据 errorCode 决定下一步(比如 NEED_APPROVAL 就转述给用户),而不是被 NullPointerException 的堆栈带偏。
  • BusinessException 单独分支 :业务拒绝(比如"订单已是已取消状态")是正常分支,不是系统错误,所以日志级别不同、错误码直接透传。

权限闸门的实现要点

java 复制代码
@Component
@RequiredArgsConstructor
public class WritePermissionGate {

    private final StringRedisTemplate redis;
    private static final Duration APPROVAL_TTL = Duration.ofMinutes(10);

    /** 判断这次写操作是否已获批准:按「会话 + 工具名 + 参数指纹」三者匹配 */
    public boolean isApproved(String sessionId, String toolName, Map<String, Object> args) {
        String key = pendingKey(sessionId);
        String approved = redis.opsForValue().get(key);
        return approved != null && approved.equals(fingerprint(toolName, args));
    }

    public void markPending(String sessionId, String toolName, Map<String, Object> args) {
        redis.opsForValue().set(pendingKey(sessionId), fingerprint(toolName, args), APPROVAL_TTL);
    }

    /** 指纹 = 工具名 + 参数,保证"批准的是这一次调用",而不是"批准了这个工具" */
    private String fingerprint(String toolName, Map<String, Object> args) {
        return toolName + ":" + new TreeMap<>(args);   // TreeMap 保证参数顺序稳定
    }
}

指纹设计是关键:批准的是"取消订单 A"这一次具体调用,不是"批准取消订单这个动作"。否则用户批准过一次,模型后续可以无限取消。

AgentLoop 的循环逻辑用流程图表达,重点标出「撞到闸门就停」和「撞到步数上限就停」两个出口:
#mermaid-svg-jNHbkaQ8GwpBLUXf{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-jNHbkaQ8GwpBLUXf .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-jNHbkaQ8GwpBLUXf .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-jNHbkaQ8GwpBLUXf .error-icon{fill:#552222;}#mermaid-svg-jNHbkaQ8GwpBLUXf .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-jNHbkaQ8GwpBLUXf .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-jNHbkaQ8GwpBLUXf .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-jNHbkaQ8GwpBLUXf .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-jNHbkaQ8GwpBLUXf .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-jNHbkaQ8GwpBLUXf .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-jNHbkaQ8GwpBLUXf .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-jNHbkaQ8GwpBLUXf .marker{fill:#333333;stroke:#333333;}#mermaid-svg-jNHbkaQ8GwpBLUXf .marker.cross{stroke:#333333;}#mermaid-svg-jNHbkaQ8GwpBLUXf svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-jNHbkaQ8GwpBLUXf p{margin:0;}#mermaid-svg-jNHbkaQ8GwpBLUXf .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-jNHbkaQ8GwpBLUXf .cluster-label text{fill:#333;}#mermaid-svg-jNHbkaQ8GwpBLUXf .cluster-label span{color:#333;}#mermaid-svg-jNHbkaQ8GwpBLUXf .cluster-label span p{background-color:transparent;}#mermaid-svg-jNHbkaQ8GwpBLUXf .label text,#mermaid-svg-jNHbkaQ8GwpBLUXf span{fill:#333;color:#333;}#mermaid-svg-jNHbkaQ8GwpBLUXf .node rect,#mermaid-svg-jNHbkaQ8GwpBLUXf .node circle,#mermaid-svg-jNHbkaQ8GwpBLUXf .node ellipse,#mermaid-svg-jNHbkaQ8GwpBLUXf .node polygon,#mermaid-svg-jNHbkaQ8GwpBLUXf .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-jNHbkaQ8GwpBLUXf .rough-node .label text,#mermaid-svg-jNHbkaQ8GwpBLUXf .node .label text,#mermaid-svg-jNHbkaQ8GwpBLUXf .image-shape .label,#mermaid-svg-jNHbkaQ8GwpBLUXf .icon-shape .label{text-anchor:middle;}#mermaid-svg-jNHbkaQ8GwpBLUXf .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-jNHbkaQ8GwpBLUXf .rough-node .label,#mermaid-svg-jNHbkaQ8GwpBLUXf .node .label,#mermaid-svg-jNHbkaQ8GwpBLUXf .image-shape .label,#mermaid-svg-jNHbkaQ8GwpBLUXf .icon-shape .label{text-align:center;}#mermaid-svg-jNHbkaQ8GwpBLUXf .node.clickable{cursor:pointer;}#mermaid-svg-jNHbkaQ8GwpBLUXf .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-jNHbkaQ8GwpBLUXf .arrowheadPath{fill:#333333;}#mermaid-svg-jNHbkaQ8GwpBLUXf .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-jNHbkaQ8GwpBLUXf .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-jNHbkaQ8GwpBLUXf .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-jNHbkaQ8GwpBLUXf .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-jNHbkaQ8GwpBLUXf .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-jNHbkaQ8GwpBLUXf .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-jNHbkaQ8GwpBLUXf .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-jNHbkaQ8GwpBLUXf .cluster text{fill:#333;}#mermaid-svg-jNHbkaQ8GwpBLUXf .cluster span{color:#333;}#mermaid-svg-jNHbkaQ8GwpBLUXf 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-jNHbkaQ8GwpBLUXf .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-jNHbkaQ8GwpBLUXf rect.text{fill:none;stroke-width:0;}#mermaid-svg-jNHbkaQ8GwpBLUXf .icon-shape,#mermaid-svg-jNHbkaQ8GwpBLUXf .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-jNHbkaQ8GwpBLUXf .icon-shape p,#mermaid-svg-jNHbkaQ8GwpBLUXf .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-jNHbkaQ8GwpBLUXf .icon-shape .label rect,#mermaid-svg-jNHbkaQ8GwpBLUXf .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-jNHbkaQ8GwpBLUXf .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-jNHbkaQ8GwpBLUXf .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-jNHbkaQ8GwpBLUXf :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
是
否
是
否
全部执行完
载入多轮记忆
step < MAX_STEPS?
返回:步骤太多,请拆解问题
调用 DeepSeek
模型要调工具?
保存记忆,返回最终答案
遍历每个 ToolCall
工具是写操作?
已批准?
标记待批准,返回 needApproval
执行工具
异常转结构化错误

四、执行层:业务副作用只留一份

Agent 调用的是 order-system 的普通接口,状态机逻辑只有一份:

java 复制代码
// order-system:取消订单 = 状态机 + 库存恢复 + 退款事件,三者同一本地事务
@Override
@Transactional(rollbackFor = Exception.class)
public void cancel(String orderNo, Long userId) {
    // ① 抢取消权:影响行数是幂等闸门(重复取消不会二次恢复库存)
    int rows = orderMapper.cancelIfCancellable(orderNo, userId);
    if (rows == 0) {
        throw new BusinessException(ErrorCode.ORDER_NOT_CANCELLABLE, "订单当前状态不可取消");
    }
    // ② 恢复库存:写一条 Outbox 事件,由调度器独立补发(不同步远程调)
    eventRecordMapper.insert(EventRecord.of(orderNo, EventType.STOCK_RESTORE));
    // ③ 退款事件:已支付订单才产生,消费后写入通知中心
    if (orderMapper.isPaid(orderNo)) {
        eventRecordMapper.insert(EventRecord.of(orderNo, EventType.REFUND));
    }
    // ④ 状态机已在上面的 UPDATE 里置为 CANCELLED
}

为什么第 ② 步不同步远程还库存? 因为远程调用会超时,超时会卡住本地事务;而回滚会把"已取消"状态一起撤掉------用户看到取消失败,但库存可能已经还了。所以改成写 Outbox 事件与取消同一个本地事务 落库,由调度器异步补发。这是典型的 Transactional Outbox:业务和事件同事务,绝不同步调远程。

Agent 拿不到完整收货地址 :order-system 的内部读接口(/internal/order/byOrderNo、改地址回显)只返回脱敏 的收货信息(姓名/手机号/地址打码)。用户端自己的订单页仍显示完整地址,但 Agent 这一侧从数据源头 就拿不到。日志层还保留 LogSanitizer 做纵深防御(拦截用户输入自由文本里的手机号)。

这一点值得展开:最初的做法是"在日志层拦截敏感信息",但自由文本里的短地址拦不全。真正的解法是最小权限 ------让 Agent 根本拿不到完整地址,而不是拿到了再想办法不打印。这是安全设计里很典型的一次思路转变。

五、可靠性设计:把"能运行"推到"能安全失败"

设计 做法 解决什么
最大执行步数 MAX_STEPS = 8,超限给可理解提示 模型停不下来、无限循环烧 token
工具超时 每次工具调用有超时 下游挂住拖死 Agent
异常转换 连不上/超时/4xx/5xx/坏 JSON/业务拒绝 → 统一结构化错误 不把 Java 堆栈喂给模型
写操作闸门 只读放行、写操作拦截,人工批准后执行 模型自主执行高风险动作
结构化日志 记录 session、步骤、工具、耗时、结果 出问题能还原现场
日志脱敏 参数按字段打码;自由文本手机号打码 + 截断 不泄露 PII
最小权限 内部读接口只返回脱敏收货信息 源头拿不到完整隐私数据
副作用归口 状态机/库存/退款只在 order-system 一份 规则不漂移

真实踩过的坑(挑 3 个最有面试价值的)

项目里记录了 10 个真实踩过、真的修好了的坑,每个都有完整的「现象 → 根因 → 修复 → 面试怎么讲」。挑三个:

坑 4:说「取消订单」,模型却去调了查单接口

  • 根因:LLM 的工具选择有随机性,提示词压不住。
  • 修复:双保险------工具描述里明确写清"用户说取消/退单时必须用本工具",同时 AgentLoop 侧对明显意图做一次兜底校验,必要时引导重试。
  • 面试怎么讲:承认模型是概率系统,然后用工程手段兜住,而不是祈祷提示词管用。

坑 9:@RequestParam Long 收到非数字返回 500

  • 现象:客户端传 ?orderNo=abc,接口 500。
  • 根因:MethodArgumentTypeMismatchException 没被全局异常处理器接住,掉进 500 兜底。
  • 修复:全局异常处理器单独接类型转换异常,返回 400。
  • 面试怎么讲:客户端输错是 4xx 问题,不是 5xx。 500 意味着"服务端崩了",会让监控告警、让排查方向跑偏。这个细节能看出你有没有真的看过线上日志。

坑 10:批准第二个写操作,模型却去取消订单

  • 现象:用户批准的是别的写操作,Agent 却执行了取消订单。
  • 根因:批准链路把"取消订单"写死在文案里 ------闸门的 reason、markApproved、前端 follow-up 消息三处都硬编码了具体工具名。
  • 修复:批准消息泛化成"继续执行你刚才要执行的写操作"。
  • 面试怎么讲:通用机制绝不能点名具体业务。 一个"权限闸门"里出现某个具体工具的名字,就是设计漏了。这是我这 10 个坑里最有价值的一个------它不是 bug,是抽象层次错误。

可靠性设计的核心是「把能运行推到能安全失败」,用一张图把各道护栏串起来:
#mermaid-svg-1ERpYR1qAT1VzeYG{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-1ERpYR1qAT1VzeYG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1ERpYR1qAT1VzeYG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1ERpYR1qAT1VzeYG .error-icon{fill:#552222;}#mermaid-svg-1ERpYR1qAT1VzeYG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1ERpYR1qAT1VzeYG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1ERpYR1qAT1VzeYG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1ERpYR1qAT1VzeYG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1ERpYR1qAT1VzeYG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1ERpYR1qAT1VzeYG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1ERpYR1qAT1VzeYG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1ERpYR1qAT1VzeYG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1ERpYR1qAT1VzeYG .marker.cross{stroke:#333333;}#mermaid-svg-1ERpYR1qAT1VzeYG svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1ERpYR1qAT1VzeYG p{margin:0;}#mermaid-svg-1ERpYR1qAT1VzeYG .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-1ERpYR1qAT1VzeYG .cluster-label text{fill:#333;}#mermaid-svg-1ERpYR1qAT1VzeYG .cluster-label span{color:#333;}#mermaid-svg-1ERpYR1qAT1VzeYG .cluster-label span p{background-color:transparent;}#mermaid-svg-1ERpYR1qAT1VzeYG .label text,#mermaid-svg-1ERpYR1qAT1VzeYG span{fill:#333;color:#333;}#mermaid-svg-1ERpYR1qAT1VzeYG .node rect,#mermaid-svg-1ERpYR1qAT1VzeYG .node circle,#mermaid-svg-1ERpYR1qAT1VzeYG .node ellipse,#mermaid-svg-1ERpYR1qAT1VzeYG .node polygon,#mermaid-svg-1ERpYR1qAT1VzeYG .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1ERpYR1qAT1VzeYG .rough-node .label text,#mermaid-svg-1ERpYR1qAT1VzeYG .node .label text,#mermaid-svg-1ERpYR1qAT1VzeYG .image-shape .label,#mermaid-svg-1ERpYR1qAT1VzeYG .icon-shape .label{text-anchor:middle;}#mermaid-svg-1ERpYR1qAT1VzeYG .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1ERpYR1qAT1VzeYG .rough-node .label,#mermaid-svg-1ERpYR1qAT1VzeYG .node .label,#mermaid-svg-1ERpYR1qAT1VzeYG .image-shape .label,#mermaid-svg-1ERpYR1qAT1VzeYG .icon-shape .label{text-align:center;}#mermaid-svg-1ERpYR1qAT1VzeYG .node.clickable{cursor:pointer;}#mermaid-svg-1ERpYR1qAT1VzeYG .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1ERpYR1qAT1VzeYG .arrowheadPath{fill:#333333;}#mermaid-svg-1ERpYR1qAT1VzeYG .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1ERpYR1qAT1VzeYG .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1ERpYR1qAT1VzeYG .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1ERpYR1qAT1VzeYG .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1ERpYR1qAT1VzeYG .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1ERpYR1qAT1VzeYG .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1ERpYR1qAT1VzeYG .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1ERpYR1qAT1VzeYG .cluster text{fill:#333;}#mermaid-svg-1ERpYR1qAT1VzeYG .cluster span{color:#333;}#mermaid-svg-1ERpYR1qAT1VzeYG 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-1ERpYR1qAT1VzeYG .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1ERpYR1qAT1VzeYG rect.text{fill:none;stroke-width:0;}#mermaid-svg-1ERpYR1qAT1VzeYG .icon-shape,#mermaid-svg-1ERpYR1qAT1VzeYG .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1ERpYR1qAT1VzeYG .icon-shape p,#mermaid-svg-1ERpYR1qAT1VzeYG .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1ERpYR1qAT1VzeYG .icon-shape .label rect,#mermaid-svg-1ERpYR1qAT1VzeYG .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1ERpYR1qAT1VzeYG .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1ERpYR1qAT1VzeYG .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1ERpYR1qAT1VzeYG :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
批准
模型调用
步数上限 MAX_STEPS=8
工具超时控制
异常统一结构化转换
写操作?
权限闸门:人工批准
执行
结构化日志 + 日志脱敏
副作用归口 order-system

六、MCP 兼容层:让 Claude Desktop 直接连

项目实现了 MCP 兼容层 (POST /mcp,Streamable HTTP transport),已通过严格客户端握手,Claude Desktop / Cursor 可以直接连接并调用工具 (演示见仓库 MCP_DEMO.md)。

意思是:同一套工具(查单、查库存、改地址、取消订单),除了自家的 Vue 聊天面板,还能被 Claude Desktop 当原生工具调用------而且写操作的人工批准闭环照样生效。

bash 复制代码
Claude Desktop / Cursor
   │  MCP 握手(initialize → tools/list → tools/call)
   ▼
order-agent-assistant  POST /mcp
   │
   └─ 复用同一套 ToolRegistry + WritePermissionGate
        └─ 写操作 → 同样需要批准,不会因为"换了客户端"就绕过闸门

这里的设计价值 :工具定义、闸门、错误转换是一份,MCP 只是一个新的协议入口。换成 MCP 客户端,安全策略不用重写------这就是"决策层和执行层分离"在架构上的红利。

MCP 兼容层的价值在于「一份工具定义,多个协议入口」,用图说明:
#mermaid-svg-EvWv7MSYGVOC5mMk{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-EvWv7MSYGVOC5mMk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-EvWv7MSYGVOC5mMk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-EvWv7MSYGVOC5mMk .error-icon{fill:#552222;}#mermaid-svg-EvWv7MSYGVOC5mMk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-EvWv7MSYGVOC5mMk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-EvWv7MSYGVOC5mMk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-EvWv7MSYGVOC5mMk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-EvWv7MSYGVOC5mMk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-EvWv7MSYGVOC5mMk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-EvWv7MSYGVOC5mMk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-EvWv7MSYGVOC5mMk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-EvWv7MSYGVOC5mMk .marker.cross{stroke:#333333;}#mermaid-svg-EvWv7MSYGVOC5mMk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-EvWv7MSYGVOC5mMk p{margin:0;}#mermaid-svg-EvWv7MSYGVOC5mMk .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-EvWv7MSYGVOC5mMk .cluster-label text{fill:#333;}#mermaid-svg-EvWv7MSYGVOC5mMk .cluster-label span{color:#333;}#mermaid-svg-EvWv7MSYGVOC5mMk .cluster-label span p{background-color:transparent;}#mermaid-svg-EvWv7MSYGVOC5mMk .label text,#mermaid-svg-EvWv7MSYGVOC5mMk span{fill:#333;color:#333;}#mermaid-svg-EvWv7MSYGVOC5mMk .node rect,#mermaid-svg-EvWv7MSYGVOC5mMk .node circle,#mermaid-svg-EvWv7MSYGVOC5mMk .node ellipse,#mermaid-svg-EvWv7MSYGVOC5mMk .node polygon,#mermaid-svg-EvWv7MSYGVOC5mMk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-EvWv7MSYGVOC5mMk .rough-node .label text,#mermaid-svg-EvWv7MSYGVOC5mMk .node .label text,#mermaid-svg-EvWv7MSYGVOC5mMk .image-shape .label,#mermaid-svg-EvWv7MSYGVOC5mMk .icon-shape .label{text-anchor:middle;}#mermaid-svg-EvWv7MSYGVOC5mMk .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-EvWv7MSYGVOC5mMk .rough-node .label,#mermaid-svg-EvWv7MSYGVOC5mMk .node .label,#mermaid-svg-EvWv7MSYGVOC5mMk .image-shape .label,#mermaid-svg-EvWv7MSYGVOC5mMk .icon-shape .label{text-align:center;}#mermaid-svg-EvWv7MSYGVOC5mMk .node.clickable{cursor:pointer;}#mermaid-svg-EvWv7MSYGVOC5mMk .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-EvWv7MSYGVOC5mMk .arrowheadPath{fill:#333333;}#mermaid-svg-EvWv7MSYGVOC5mMk .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-EvWv7MSYGVOC5mMk .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-EvWv7MSYGVOC5mMk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EvWv7MSYGVOC5mMk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-EvWv7MSYGVOC5mMk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EvWv7MSYGVOC5mMk .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-EvWv7MSYGVOC5mMk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-EvWv7MSYGVOC5mMk .cluster text{fill:#333;}#mermaid-svg-EvWv7MSYGVOC5mMk .cluster span{color:#333;}#mermaid-svg-EvWv7MSYGVOC5mMk 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-EvWv7MSYGVOC5mMk .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-EvWv7MSYGVOC5mMk rect.text{fill:none;stroke-width:0;}#mermaid-svg-EvWv7MSYGVOC5mMk .icon-shape,#mermaid-svg-EvWv7MSYGVOC5mMk .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EvWv7MSYGVOC5mMk .icon-shape p,#mermaid-svg-EvWv7MSYGVOC5mMk .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-EvWv7MSYGVOC5mMk .icon-shape .label rect,#mermaid-svg-EvWv7MSYGVOC5mMk .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EvWv7MSYGVOC5mMk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-EvWv7MSYGVOC5mMk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-EvWv7MSYGVOC5mMk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 执行层
决策层 order-agent-assistant
客户端
HTTP /query
MCP 握手 initialize→tools/list→tools/call
MCP 握手
内部密钥调用
Vue3 聊天面板
Claude Desktop
Cursor
ToolRegistry 工具定义(一份)
WritePermissionGate 闸门(一份)
错误转换(一份)
order-system 业务接口

七、测试与 CI

分三层,越往下越接近真环境:

  • 单元测试(主力) :纯 Mockito,不依赖中间件,任何机器(含 CI 空机器)都能跑绿。

    bash 复制代码
    cd order-system && mvn test           # 49 个用例:状态机/通知中心/脱敏/商品图片/订单归属/事件重试
    cd order-agent-assistant && mvn test  # 146 个用例:AgentLoop/闸门/会话存储/工具/限流/请求体上限/脱敏/MCP 握手

    Redis、Feign、消息队列这些外部依赖在单测里全部用 mock 替身------既保证测试可移植,也逼着业务代码面向接口。

  • 端到端冒烟(scripts/smoke.sh) :起真 MySQL/Redis + 两个后端,验证单测隔离中间件测不到的协作问题 ------内部密钥配对、收货信息脱敏、写操作闸门。本地也能 bash scripts/smoke.sh 跑。

  • CI(GitHub Actions) :.github/workflows/ci.yml,每次 push / PR 自动跑三个并行任务(order-system 测试、agent 测试、前端生产构建),全绿后再跑端到端冒烟。

为什么单测要 mock 掉 Redis? 不是偷懒。单测的定位是"验证我的业务逻辑分支对不对",它不该被环境问题干扰;而"我的代码和真 Redis 协作对不对"是冒烟测试的职责。测试分层不是省事,是让每一层只回答一个问题。

八、面试 / 简历亮点

简历可以这么写(带量化指标):

独立完成 AI Agent 管理订单的全栈作品:Vue3 前端 + Spring Boot 决策层 + 业务执行层三层架构。实现 Agent 循环 + 写操作人工批准闸门 + Redis 多轮记忆 + DeepSeek 工具调用 ,并实现 MCP 兼容层(Streamable HTTP) ,已被 Claude Desktop / Cursor 成功握手调用。设计并落地 Agent 可靠性方案:最大步数限制、工具异常统一结构化转换、日志脱敏、收货信息源头最小权限。测试分层:195 个 Mockito 单测(order-system 49 + agent 146)+ 端到端冒烟 + GitHub Actions 三并行任务 CI。

讲这个项目的三句核心:

  1. 模型负责决策,代码负责执行------业务规则只在执行层一份,Agent 不直连数据库。这句话能把项目从"调 API 的 Demo"拉到"架构设计"的层次。
  2. 写操作必须人工批准------用"会话 + 工具名 + 参数指纹"做闸门键,批准的是"这一次调用"而不是"这个工具"。
  3. 模型是概率系统,要靠工程兜住------步数上限、异常转换、意图双保险,都是承认随机性之后的设计。

九、场景题(面试深水区)

场景题 1:如果 Agent 需要连续执行多个写操作(比如"取消订单 A 并把订单 B 的地址改成 XX"),你的闸门怎么设计?

这是"单次批准"模型的天然短板,讲清权衡比给答案更重要:

  • 不能"批准一次管整个会话"。闸门一旦放松成会话级,"批准"就退化成了摆设------用户批准了改地址,模型顺手取消了订单。
  • 正确方向是"计划级批准" :先让模型把整个动作序列(有哪些写操作、各自参数)一次性列出来给用户确认,用户批准这一个"计划指纹",然后循环执行时校验每次调用是否属于已批准计划。
  • 但要引入新的复杂度:计划执行到一半失败怎么办(部分成功 → 需要补偿/回滚语义);计划里的参数在执行时被模型改动了怎么办(校验必须锁死参数,不能只锁工具名)。
  • 务实结论 :本项目只落地了单次写操作批准 (够用且边界清晰)。多步写操作编排属于尚未落地的演进方向,上面讲的是设计思路而不是已实现的功能------面试时要把这个区分讲清楚。

场景题 2:模型调用工具超时了,但下游其实执行成功了,怎么保证不重复执行?

这是 AI Agent 版经典的"超时 ≠ 失败"问题,本质和微服务调用的幂等是同一个问题:

  • 默认必须假设"超时=结果未知",不能重试。写操作盲目重试就是重复退款/重复取消。
  • 解法是把幂等责任下沉到执行层 :order-system 的取消接口用 cancelIfCancellable(影响行数闸门)+ 状态机兜底------已经 CANCELLED 的订单再取消一次,影响行数 0,直接报"状态不可取消",不会二次恢复库存、不会二次产生退款事件。
  • Agent 层的正确处置 :超时后不要自动重试 ,而是把 {"success":false,"errorCode":"TIMEOUT"} 交给模型,让它转述给用户:"下游响应超时,结果不确定,请查询订单当前状态后再决定。" 由人来做最终确认------这在有资损风险的场景里不是保守,是正确。
  • 延伸:所有写操作工具都应该带上幂等键(比如本次会话的 requestId),这样即使重试也是无害回放。这是从微服务幂等设计里直接迁移过来的经验。

一句话收尾 :AI Agent 接入真实业务系统的难点,从来不是"怎么让模型调通工具"------那是一天的事。难点是模型不可靠、业务规则不能有两份、写操作不能自主执行这三件事,以及围绕它们的一整套护栏设计。把这个想清楚了,你的项目才从"AI Demo"变成"AI 系统"。

推荐标签:AI Agent、Spring Boot、Vue3、Function Calling、MCP

相关推荐
·云扬·1 小时前
Prompt 工程与安全:从 System 设计到注入防御
java·人工智能·安全·ai·prompt
阿明副业观察2 小时前
2026年免费AI视频生成工具功能深度解析:有无水印、中文语音及商业用途考量
大数据·人工智能·ai作画·aigc·音视频·ai写作
收放扳机2 小时前
PCB收放板设备机械手架构选型参考——三轴/四轴/六轴/蜘蛛手对比
人工智能·科技·架构·自动化·制造·pcb工艺
九卷2 小时前
企业级Agent系统整体技术架构介绍
ai·架构·大模型·llm·agent·智能体
AKAMAI2 小时前
Akamai 宣布与Anthropic达成 116 亿美元多年期协议,以支持其日益增长的需求
人工智能·云计算
甲维斯2 小时前
我把“赶 Deadline”和AI做成了一款游戏
人工智能·游戏开发
Smoothcloud润云3 小时前
PyTorch大模型权重加载与启动耗时优化实战:定位磁盘、内存与显存瓶颈
人工智能·pytorch·python·深度学习·gpu算力
hsfxuebao3 小时前
380K Star 的 OpenClaw 凭什么碾压 Hermes Agent?源码级架构对决——从 Gateway 到 Agent Loop 的五维拆解
人工智能
唠点键盘之外的3 小时前
26 向量数据库实战:从 0 搭一条能用的检索链路(pgvector + Spring AI)
数据库·人工智能·spring