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,但在企业里活不过第一天。原因是三条硬约束:
- 模型是概率系统,不是确定性系统 。同一句话,这次它调
cancel_order,下次它可能调get_order。你无法用提示词 100% 约束它(这个坑我真实踩过,见第五节第 4 条)。 - 业务规则不能有两份 。订单状态机、库存恢复、退款事件,这些逻辑已经在
order-system里了。如果 Agent 也写一遍,两份规则迟早漂移------改了一边忘了另一边,就是线上事故。 - 写操作不能由模型单方面决定。让一个概率系统自主执行"取消订单并退款",等于把资损风险交给随机性。
所以正确的架构不是"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 空机器)都能跑绿。
bashcd 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。
讲这个项目的三句核心:
- 模型负责决策,代码负责执行------业务规则只在执行层一份,Agent 不直连数据库。这句话能把项目从"调 API 的 Demo"拉到"架构设计"的层次。
- 写操作必须人工批准------用"会话 + 工具名 + 参数指纹"做闸门键,批准的是"这一次调用"而不是"这个工具"。
- 模型是概率系统,要靠工程兜住------步数上限、异常转换、意图双保险,都是承认随机性之后的设计。
九、场景题(面试深水区)
场景题 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