一、业务背景:一个"偶尔抽风"的 Agent
我在做一个 AI 订单助手 (order-agent-assistant):用户在网页右下角的聊天面板里用自然语言说话,Agent 负责调工具去查单、查库存、改收货地址、取消订单。取消已支付订单会走状态机、恢复库存、并由 Transactional Outbox 落一条退款事件,通知中心生成真实退款通知。
核心链路是这样的:
bash
用户: "帮我取消订单 20260819104345b78ccb"
→ LLM 决定调 cancel_order
→ 权限闸门(写操作,未批准)拦下
→ 模型转述"需要人工确认后才能执行"
→ 前端聊天气泡弹出「批准执行」按钮
→ 点击 → POST /approve 放行 → 真正执行取消
这套设计我自认为挺完整:模型负责决策,代码负责执行和搬运;写操作必须人工批准;步数上限 8 步防死循环。直到我把这个项目拿去演示,翻车了。
我对 Agent 说:"帮我取消订单 20260819104345b78ccb"。
它回答:"好的,我来帮您查询一下这个订单的状态。" ------ 然后调用了 query_order。
查完还特别礼貌地问我:"请问需要我帮您取消吗?"
权限闸门根本没被触发------因为压根没有写操作发生,前端的「批准执行」按钮自然出不来。演示的人一脸茫然:"它不是能取消订单吗?"
这个问题最恶心的地方在于:它不是必现的。同一句话,十次里有七八次是正常的,剩下两三次就跑偏。你没法复现,也没法调试,只能靠运气。
下面是核心链路的完整流程:
#mermaid-svg-tUiRXW5t7AHK3WfU{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-tUiRXW5t7AHK3WfU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-tUiRXW5t7AHK3WfU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-tUiRXW5t7AHK3WfU .error-icon{fill:#552222;}#mermaid-svg-tUiRXW5t7AHK3WfU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-tUiRXW5t7AHK3WfU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-tUiRXW5t7AHK3WfU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-tUiRXW5t7AHK3WfU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-tUiRXW5t7AHK3WfU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-tUiRXW5t7AHK3WfU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-tUiRXW5t7AHK3WfU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-tUiRXW5t7AHK3WfU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-tUiRXW5t7AHK3WfU .marker.cross{stroke:#333333;}#mermaid-svg-tUiRXW5t7AHK3WfU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-tUiRXW5t7AHK3WfU p{margin:0;}#mermaid-svg-tUiRXW5t7AHK3WfU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-tUiRXW5t7AHK3WfU .cluster-label text{fill:#333;}#mermaid-svg-tUiRXW5t7AHK3WfU .cluster-label span{color:#333;}#mermaid-svg-tUiRXW5t7AHK3WfU .cluster-label span p{background-color:transparent;}#mermaid-svg-tUiRXW5t7AHK3WfU .label text,#mermaid-svg-tUiRXW5t7AHK3WfU span{fill:#333;color:#333;}#mermaid-svg-tUiRXW5t7AHK3WfU .node rect,#mermaid-svg-tUiRXW5t7AHK3WfU .node circle,#mermaid-svg-tUiRXW5t7AHK3WfU .node ellipse,#mermaid-svg-tUiRXW5t7AHK3WfU .node polygon,#mermaid-svg-tUiRXW5t7AHK3WfU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-tUiRXW5t7AHK3WfU .rough-node .label text,#mermaid-svg-tUiRXW5t7AHK3WfU .node .label text,#mermaid-svg-tUiRXW5t7AHK3WfU .image-shape .label,#mermaid-svg-tUiRXW5t7AHK3WfU .icon-shape .label{text-anchor:middle;}#mermaid-svg-tUiRXW5t7AHK3WfU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-tUiRXW5t7AHK3WfU .rough-node .label,#mermaid-svg-tUiRXW5t7AHK3WfU .node .label,#mermaid-svg-tUiRXW5t7AHK3WfU .image-shape .label,#mermaid-svg-tUiRXW5t7AHK3WfU .icon-shape .label{text-align:center;}#mermaid-svg-tUiRXW5t7AHK3WfU .node.clickable{cursor:pointer;}#mermaid-svg-tUiRXW5t7AHK3WfU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-tUiRXW5t7AHK3WfU .arrowheadPath{fill:#333333;}#mermaid-svg-tUiRXW5t7AHK3WfU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-tUiRXW5t7AHK3WfU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-tUiRXW5t7AHK3WfU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-tUiRXW5t7AHK3WfU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-tUiRXW5t7AHK3WfU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-tUiRXW5t7AHK3WfU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-tUiRXW5t7AHK3WfU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-tUiRXW5t7AHK3WfU .cluster text{fill:#333;}#mermaid-svg-tUiRXW5t7AHK3WfU .cluster span{color:#333;}#mermaid-svg-tUiRXW5t7AHK3WfU 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-tUiRXW5t7AHK3WfU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-tUiRXW5t7AHK3WfU rect.text{fill:none;stroke-width:0;}#mermaid-svg-tUiRXW5t7AHK3WfU .icon-shape,#mermaid-svg-tUiRXW5t7AHK3WfU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-tUiRXW5t7AHK3WfU .icon-shape p,#mermaid-svg-tUiRXW5t7AHK3WfU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-tUiRXW5t7AHK3WfU .icon-shape .label rect,#mermaid-svg-tUiRXW5t7AHK3WfU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-tUiRXW5t7AHK3WfU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-tUiRXW5t7AHK3WfU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-tUiRXW5t7AHK3WfU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 拦下
用户: 帮我取消订单 20260819104345b78ccb
LLM 决定调 cancel_order
权限闸门
写操作未批准
模型转述: 需要人工确认
前端弹出「批准执行」按钮
用户点击批准
POST /approve 放行
真正执行取消
状态机 + 恢复库存 + Outbox 退款事件
二、根因:LLM 的工具选择是概率行为,不是函数调用
先想清楚一件事:我们把 LLM 当成一个"决策函数",但它实际输出的是一个概率分布。
cancel_order 和 query_order 这两个工具的语义边界,在人看来是清楚的,在模型的 token 空间里却是模糊的------"帮我取消订单 X" 这句话,模型完全可以合理地解释成两步:
- 先查一下这个订单存不存在、是不是已支付(
query_order) - 再决定要不要取消
这是教科书式的"合理"行为,很多 prompt engineering 教程还鼓励模型"先思考再行动"。但对我的业务来说,这是错的------因为「查单」不是写操作,闸门拦不到它,用户看到的就是一个"它开始查单了"的假象。
所以问题不是"模型太笨",而是我把关键路径的稳定性,压在了一个概率系统上。
我的第一反应当然是改提示词。但很快发现:光靠提示词压不住。 提示词是"软指令",它影响概率分布,但没法把概率变成 1。而且模型有上下文污染------多轮对话里如果之前聊过查单,它更容易延续"查单"的路径。
结论:凡是演示的、付费的、会出事故的关键路径,都不能只靠提示词赌模型稳定。必须加代码层的确定性兜底。
我最后做的是双保险:后端压随机性 + 前端确定性重定向。
用一张图说明"概率行为"导致的工具选择漂移:
#mermaid-svg-GUhsSiDoDCDw5OTz{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-GUhsSiDoDCDw5OTz .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-GUhsSiDoDCDw5OTz .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-GUhsSiDoDCDw5OTz .error-icon{fill:#552222;}#mermaid-svg-GUhsSiDoDCDw5OTz .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-GUhsSiDoDCDw5OTz .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-GUhsSiDoDCDw5OTz .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-GUhsSiDoDCDw5OTz .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-GUhsSiDoDCDw5OTz .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-GUhsSiDoDCDw5OTz .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-GUhsSiDoDCDw5OTz .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-GUhsSiDoDCDw5OTz .marker{fill:#333333;stroke:#333333;}#mermaid-svg-GUhsSiDoDCDw5OTz .marker.cross{stroke:#333333;}#mermaid-svg-GUhsSiDoDCDw5OTz svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-GUhsSiDoDCDw5OTz p{margin:0;}#mermaid-svg-GUhsSiDoDCDw5OTz .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-GUhsSiDoDCDw5OTz .cluster-label text{fill:#333;}#mermaid-svg-GUhsSiDoDCDw5OTz .cluster-label span{color:#333;}#mermaid-svg-GUhsSiDoDCDw5OTz .cluster-label span p{background-color:transparent;}#mermaid-svg-GUhsSiDoDCDw5OTz .label text,#mermaid-svg-GUhsSiDoDCDw5OTz span{fill:#333;color:#333;}#mermaid-svg-GUhsSiDoDCDw5OTz .node rect,#mermaid-svg-GUhsSiDoDCDw5OTz .node circle,#mermaid-svg-GUhsSiDoDCDw5OTz .node ellipse,#mermaid-svg-GUhsSiDoDCDw5OTz .node polygon,#mermaid-svg-GUhsSiDoDCDw5OTz .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-GUhsSiDoDCDw5OTz .rough-node .label text,#mermaid-svg-GUhsSiDoDCDw5OTz .node .label text,#mermaid-svg-GUhsSiDoDCDw5OTz .image-shape .label,#mermaid-svg-GUhsSiDoDCDw5OTz .icon-shape .label{text-anchor:middle;}#mermaid-svg-GUhsSiDoDCDw5OTz .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-GUhsSiDoDCDw5OTz .rough-node .label,#mermaid-svg-GUhsSiDoDCDw5OTz .node .label,#mermaid-svg-GUhsSiDoDCDw5OTz .image-shape .label,#mermaid-svg-GUhsSiDoDCDw5OTz .icon-shape .label{text-align:center;}#mermaid-svg-GUhsSiDoDCDw5OTz .node.clickable{cursor:pointer;}#mermaid-svg-GUhsSiDoDCDw5OTz .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-GUhsSiDoDCDw5OTz .arrowheadPath{fill:#333333;}#mermaid-svg-GUhsSiDoDCDw5OTz .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-GUhsSiDoDCDw5OTz .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-GUhsSiDoDCDw5OTz .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-GUhsSiDoDCDw5OTz .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-GUhsSiDoDCDw5OTz .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-GUhsSiDoDCDw5OTz .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-GUhsSiDoDCDw5OTz .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-GUhsSiDoDCDw5OTz .cluster text{fill:#333;}#mermaid-svg-GUhsSiDoDCDw5OTz .cluster span{color:#333;}#mermaid-svg-GUhsSiDoDCDw5OTz 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-GUhsSiDoDCDw5OTz .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-GUhsSiDoDCDw5OTz rect.text{fill:none;stroke-width:0;}#mermaid-svg-GUhsSiDoDCDw5OTz .icon-shape,#mermaid-svg-GUhsSiDoDCDw5OTz .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-GUhsSiDoDCDw5OTz .icon-shape p,#mermaid-svg-GUhsSiDoDCDw5OTz .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-GUhsSiDoDCDw5OTz .icon-shape .label rect,#mermaid-svg-GUhsSiDoDCDw5OTz .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-GUhsSiDoDCDw5OTz .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-GUhsSiDoDCDw5OTz .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-GUhsSiDoDCDw5OTz :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户输入: 帮我取消订单 X
LLM 概率分布
cancel_order
概率 70%
query_order
概率 30%
✅ 正确路径
触发闸门 → 批准
❌ 跑偏路径
查单 → 闸门拦不到
用户看到: 它在查单
批准按钮出不来
三、第一层保险:后端压随机性,把概率往 1 推
这一层做两件事:参数层面降温度 ,提示词层面写死硬规则。
3.1 温度降到 0
调用 DeepSeek 时,我把 temperature 设成 0:
java
// DeepSeekLlmClient.java ------ 去程:Java 对象 → 请求 JSON
private ObjectNode buildBody(List<Message> messages, List<Tool> tools) {
ObjectNode body = json.createObjectNode();
body.put("model", model);
// 低温度:工具选择尽量稳定,别让同一句话这次取消、下次却去查单
body.put("temperature", 0);
// 强制最终回答输出 JSON(OpenAI 兼容的 JSON mode)
ObjectNode fmt = body.putObject("response_format");
fmt.put("type", "json_object");
ArrayNode msgs = body.putArray("messages");
for (Message m : messages) {
// ... 按 role 组装 system / user / assistant(tool_calls) / tool(tool_call_id)
}
ArrayNode toolArr = body.putArray("tools");
for (Tool t : tools) {
ObjectNode toolNode = toolArr.addObject();
toolNode.put("type", "function");
ObjectNode fn = toolNode.putObject("function");
fn.put("name", t.name());
fn.put("description", t.description());
fn.set("parameters", json.valueToTree(t.inputSchema()));
}
return body;
}
temperature: 0 是贪心解码,同一输入尽量给同一输出。它不能保证 100% 确定性(模型服务端的批处理、版本、浮点误差都可能让结果抖动),但它能显著削掉"同一句话这次取消下次查单"的随机性。
3.2 系统提示词里写死"禁止路径"
光降温度不够,还得明确告诉模型在这个业务里什么是错的行为。我的系统提示词里,工具选择规则是当成硬约束写的:
java
// RedisSessionStore.java ------ 会话首次创建时注入的系统提示词
private static final String SYSTEM_PROMPT =
"你是订单助手,负责帮用户查询和管理订单,始终用中文回答。\n" +
"【核心规则:按用户意图选工具,禁止用错工具】\n" +
"- 用户消息里出现"取消"(如"帮我取消订单 XX")→ 立即调用 cancel_order 工具," +
"禁止调用 query_order,禁止反问"是否要取消",禁止先查单再决定。\n" +
"- 用户只问状态/查询(如"帮我查一下订单")→ 调用 query_order 工具。\n" +
"- 调用 cancel_order 被拦截(工具返回"写操作被拦截")→ 向用户转告:" +
""取消订单需要人工确认后才能执行",并附上订单号。\n" +
"- 用户表示"已批准/已确认"后 → 再次调用 cancel_order 工具真正执行。\n" +
"- 工具返回 JSON 里 success=false 表示执行失败 → 把 message 里的原因原样转告给用户," +
"禁止编造成功结果。\n" +
"【输出格式】最终回答必须是一个 JSON 对象,禁止 Markdown 代码块、禁止任何多余文字," +
"字段固定为:answer、orderNo、status、amount。";
注意几个写法上的细节,都是踩出来的经验:
- "禁止反问'是否要取消'" ------ 模型很喜欢"体贴"地二次确认,但我的批准机制已经承担了"人工确认"这个职责,模型再问一次就是双重确认,用户会觉得啰嗦。
- "禁止先查单再决定" ------ 直接堵死第一节说的那条"看起来合理"的路径。
temperature: 0+ JSON mode 并存 ------ DeepSeek 的response_format: json_object和 function calling 可以同时用,不冲突。但要记住:json_object只保证是合法 JSON,不保证符合你的 schema,而且 prompt 里必须出现 "json" 字样,否则接口会报错。所以我的提示词里最后一段专门把输出字段写死了。
到这里,随机性已经被压得很低了。但依然不是 0。所以还需要第二层。
第一层保险的完整示意:
#mermaid-svg-CAsZhyOfQghSRHa6{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-CAsZhyOfQghSRHa6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-CAsZhyOfQghSRHa6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-CAsZhyOfQghSRHa6 .error-icon{fill:#552222;}#mermaid-svg-CAsZhyOfQghSRHa6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-CAsZhyOfQghSRHa6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-CAsZhyOfQghSRHa6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-CAsZhyOfQghSRHa6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-CAsZhyOfQghSRHa6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-CAsZhyOfQghSRHa6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-CAsZhyOfQghSRHa6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-CAsZhyOfQghSRHa6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-CAsZhyOfQghSRHa6 .marker.cross{stroke:#333333;}#mermaid-svg-CAsZhyOfQghSRHa6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-CAsZhyOfQghSRHa6 p{margin:0;}#mermaid-svg-CAsZhyOfQghSRHa6 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-CAsZhyOfQghSRHa6 .cluster-label text{fill:#333;}#mermaid-svg-CAsZhyOfQghSRHa6 .cluster-label span{color:#333;}#mermaid-svg-CAsZhyOfQghSRHa6 .cluster-label span p{background-color:transparent;}#mermaid-svg-CAsZhyOfQghSRHa6 .label text,#mermaid-svg-CAsZhyOfQghSRHa6 span{fill:#333;color:#333;}#mermaid-svg-CAsZhyOfQghSRHa6 .node rect,#mermaid-svg-CAsZhyOfQghSRHa6 .node circle,#mermaid-svg-CAsZhyOfQghSRHa6 .node ellipse,#mermaid-svg-CAsZhyOfQghSRHa6 .node polygon,#mermaid-svg-CAsZhyOfQghSRHa6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-CAsZhyOfQghSRHa6 .rough-node .label text,#mermaid-svg-CAsZhyOfQghSRHa6 .node .label text,#mermaid-svg-CAsZhyOfQghSRHa6 .image-shape .label,#mermaid-svg-CAsZhyOfQghSRHa6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-CAsZhyOfQghSRHa6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-CAsZhyOfQghSRHa6 .rough-node .label,#mermaid-svg-CAsZhyOfQghSRHa6 .node .label,#mermaid-svg-CAsZhyOfQghSRHa6 .image-shape .label,#mermaid-svg-CAsZhyOfQghSRHa6 .icon-shape .label{text-align:center;}#mermaid-svg-CAsZhyOfQghSRHa6 .node.clickable{cursor:pointer;}#mermaid-svg-CAsZhyOfQghSRHa6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-CAsZhyOfQghSRHa6 .arrowheadPath{fill:#333333;}#mermaid-svg-CAsZhyOfQghSRHa6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-CAsZhyOfQghSRHa6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-CAsZhyOfQghSRHa6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CAsZhyOfQghSRHa6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-CAsZhyOfQghSRHa6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CAsZhyOfQghSRHa6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-CAsZhyOfQghSRHa6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-CAsZhyOfQghSRHa6 .cluster text{fill:#333;}#mermaid-svg-CAsZhyOfQghSRHa6 .cluster span{color:#333;}#mermaid-svg-CAsZhyOfQghSRHa6 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-CAsZhyOfQghSRHa6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-CAsZhyOfQghSRHa6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-CAsZhyOfQghSRHa6 .icon-shape,#mermaid-svg-CAsZhyOfQghSRHa6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CAsZhyOfQghSRHa6 .icon-shape p,#mermaid-svg-CAsZhyOfQghSRHa6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-CAsZhyOfQghSRHa6 .icon-shape .label rect,#mermaid-svg-CAsZhyOfQghSRHa6 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CAsZhyOfQghSRHa6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-CAsZhyOfQghSRHa6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-CAsZhyOfQghSRHa6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户输入
temperature = 0
贪心解码
系统提示词硬约束
禁止查单/禁止反问
LLM 工具选择
cancel_order
概率 ↑ 接近 1
query_order
概率 ↓ 接近 0
✅ 进入闸门
❌ 仍有小概率跑偏
需要第二层保险
四、第二层保险:前端确定性兜底,把跑偏的模型拉回来
这一层是整个方案里我最满意的部分。思路是:
不跟概率系统讲道理,而是用确定性代码去检测"它跑偏了",然后把人话重新说一遍。
具体来说,前端在拿到模型回答之后,做一次意图校验:
- 用户原话表达的是"取消"意图吗?
- 这句话里能提取到订单号吗?
- 模型的回答里,既没有触发批准按钮,也没有报告取消成功吗?
三条全中 = 模型跑偏了 → 自动补发一句明确的命令,把它拽回取消路径。
4.1 判定工具(纯函数,好测)
javascript
// frontend/src/utils/agent.js
const CONFIRM_KW = ['人工确认', '需要确认', '人工客服确认', '写操作被拦截']
/** 订单号:14 位时间戳 + 4 位以上 hex,如 20260819104345b78ccb */
const ORDER_NO_RE = /\b\d{14}[0-9a-f]{4,}\b/i
export function shouldShowApprove(answer, lastUserText) {
const a = answer || ''
if (CONFIRM_KW.some((k) => a.includes(k))) return true
if ((lastUserText || '').includes('取消') && a.includes('确认')) return true
return false
}
export function extractOrderNo(text) {
const m = (text || '').match(ORDER_NO_RE)
return m ? m[0] : ''
}
/** 用户原话是否表达取消意图(用于兜底:模型没走取消路径时把它拉回来) */
export function hasCancelIntent(text) {
return /取消|退单|退款/.test(text || '')
}
/** 取消是否真正生效(决定订单列表要不要刷新) */
export function isCancelSuccess(answer, status) {
if ((status || '') === 'CANCELLED') return true
return /已成功取消|已取消|取消成功/.test(answer || '')
}
这里有个设计取舍值得说:为什么用关键词而不是再叫一次 LLM 做意图识别?
因为兜底的意义就在于确定性 。如果我用一个概率系统去纠正另一个概率系统,那只是把不确定性挪了个地方------而且引入了双倍延迟和双倍成本。正则和关键词看起来"土",但它是可预测的:给同样的输入永远给同样的输出,还能写单元测试。
4.2 重定向逻辑
javascript
// frontend/src/stores/chat.js ------ send() 的核心片段
let res = await agentApi.agentQuery(text, this.sessionId || undefined)
this.sessionId = res.sessionId || this.sessionId
// 取消意图兜底:模型偶尔把「取消」误判成「查单」,闸门就拦不到、批准按钮出不来。
// 检测到这种情况,自动补一句命令把它拉回取消路径(中间那轮不展示,不污染对话)。
const orderNo = res.orderNo || extractOrderNo(text)
if (hasCancelIntent(text) && orderNo &&
!shouldShowApprove(res.answer || '', text) &&
!isCancelSuccess(res.answer || '', res.status)) {
const steer = `请立即调用 cancel_order 工具执行取消订单 ${orderNo},不要再查单,不要再询问确认。`
res = await agentApi.agentQuery(steer, this.sessionId)
}
这段代码有三个我特意处理的点:
- 中间那轮不展示给用户。 模型跑偏的那次回答直接被丢弃,用户看不到"我先帮您查一下"这种让人困惑的中间态。对话是干净的。
- 复用同一个 sessionId。 补发的命令走的是同一个服务端会话,多轮记忆连续,模型能看到自己刚才做了什么,不会重新开始。
- 补发命令本身也是"人话"。 它不是直接调接口去取消订单------那样就绕过了模型,也绕过了闸门。它只是把用户的意图再说清楚一遍,剩下的决策权还在模型手里,闸门依然拦得住。这条边界很重要:兜底是为了让模型回到正确路径,不是为了替模型做决策。
第二层保险的完整流程:
#mermaid-svg-45nDsuMYATfGwZan{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-45nDsuMYATfGwZan .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-45nDsuMYATfGwZan .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-45nDsuMYATfGwZan .error-icon{fill:#552222;}#mermaid-svg-45nDsuMYATfGwZan .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-45nDsuMYATfGwZan .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-45nDsuMYATfGwZan .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-45nDsuMYATfGwZan .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-45nDsuMYATfGwZan .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-45nDsuMYATfGwZan .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-45nDsuMYATfGwZan .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-45nDsuMYATfGwZan .marker{fill:#333333;stroke:#333333;}#mermaid-svg-45nDsuMYATfGwZan .marker.cross{stroke:#333333;}#mermaid-svg-45nDsuMYATfGwZan svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-45nDsuMYATfGwZan p{margin:0;}#mermaid-svg-45nDsuMYATfGwZan .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-45nDsuMYATfGwZan .cluster-label text{fill:#333;}#mermaid-svg-45nDsuMYATfGwZan .cluster-label span{color:#333;}#mermaid-svg-45nDsuMYATfGwZan .cluster-label span p{background-color:transparent;}#mermaid-svg-45nDsuMYATfGwZan .label text,#mermaid-svg-45nDsuMYATfGwZan span{fill:#333;color:#333;}#mermaid-svg-45nDsuMYATfGwZan .node rect,#mermaid-svg-45nDsuMYATfGwZan .node circle,#mermaid-svg-45nDsuMYATfGwZan .node ellipse,#mermaid-svg-45nDsuMYATfGwZan .node polygon,#mermaid-svg-45nDsuMYATfGwZan .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-45nDsuMYATfGwZan .rough-node .label text,#mermaid-svg-45nDsuMYATfGwZan .node .label text,#mermaid-svg-45nDsuMYATfGwZan .image-shape .label,#mermaid-svg-45nDsuMYATfGwZan .icon-shape .label{text-anchor:middle;}#mermaid-svg-45nDsuMYATfGwZan .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-45nDsuMYATfGwZan .rough-node .label,#mermaid-svg-45nDsuMYATfGwZan .node .label,#mermaid-svg-45nDsuMYATfGwZan .image-shape .label,#mermaid-svg-45nDsuMYATfGwZan .icon-shape .label{text-align:center;}#mermaid-svg-45nDsuMYATfGwZan .node.clickable{cursor:pointer;}#mermaid-svg-45nDsuMYATfGwZan .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-45nDsuMYATfGwZan .arrowheadPath{fill:#333333;}#mermaid-svg-45nDsuMYATfGwZan .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-45nDsuMYATfGwZan .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-45nDsuMYATfGwZan .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-45nDsuMYATfGwZan .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-45nDsuMYATfGwZan .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-45nDsuMYATfGwZan .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-45nDsuMYATfGwZan .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-45nDsuMYATfGwZan .cluster text{fill:#333;}#mermaid-svg-45nDsuMYATfGwZan .cluster span{color:#333;}#mermaid-svg-45nDsuMYATfGwZan 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-45nDsuMYATfGwZan .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-45nDsuMYATfGwZan rect.text{fill:none;stroke-width:0;}#mermaid-svg-45nDsuMYATfGwZan .icon-shape,#mermaid-svg-45nDsuMYATfGwZan .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-45nDsuMYATfGwZan .icon-shape p,#mermaid-svg-45nDsuMYATfGwZan .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-45nDsuMYATfGwZan .icon-shape .label rect,#mermaid-svg-45nDsuMYATfGwZan .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-45nDsuMYATfGwZan .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-45nDsuMYATfGwZan .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-45nDsuMYATfGwZan :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
是
否
是
否
用户输入: 取消订单 X
调用 agentQuery
LLM 返回回答
hasCancelIntent
用户是取消意图?
正常展示
提取到订单号?
shouldShowApprove
已触发批准?
isCancelSuccess
已取消成功?
补发命令: 请立即调用 cancel_order
复用同一 sessionId 重发
LLM 回到取消路径
触发闸门 → 批准
五、第三层:闸门本身就是确定性边界(fail-closed)
上面两层都是在"引导模型选对工具"。但工程上还有一个更根本的问题:
万一模型真的选错了写操作呢?万一选对了工具但参数错了呢?
这就是权限闸门存在的意义。它不依赖模型的正确性,而是默认不信任:
java
// WritePermissionGate.java
@Override
public boolean blocks(ToolCall call, String sessionId, Long userId) {
if (!writeTools.contains(call.name())) {
return false; // 只读 → 放行
}
// 原子抢占批准:claim = "读+比对+删" 一步完成(Redis Lua / ConcurrentHashMap.remove(key, value))
if (store.claim(userId, sessionId, call.name(), fingerprint(call.args()))) {
return false;
}
pendingStore.save(userId, sessionId, call.name(), fingerprint(call.args()));
return true; // 拦下,等 /approve
}
/** 参数指纹:Map → 规范化 JSON(TreeMap 按 key 排序),保证同样的参数不管 key 顺序如何都产出同一字符串 */
private String fingerprint(Map<String, Object> args) {
try {
return json.writeValueAsString(json.valueToTree(new TreeMap<>(args)));
} catch (JsonProcessingException e) {
return null; // 序列化失败 → null → 匹配不上任何批准 → fail-closed 拦下
}
}
这里有几个确定性保证,全是代码给的,跟模型稳不稳定无关:
- 写工具集合是从工具自身的
readOnly()推导的 ,不是手写白名单。新加一个写工具,只要没标readOnly=true,自动默认拦截。默认拦比默认放安全。 - 一次批准只放行"那一次调用" :绑定
(userId, sessionId, toolName, 参数指纹)。模型中途换订单号、换工具、换用户、换会话、或者批准过期(TTL),一律重新批准。 - claim 是原子操作 (Redis Lua /
ConcurrentHashMap.remove(key, value)):同一个批准凭证被两个并发请求同时抢,只有一个抢得到。批准绝不会被用两次。 - 序列化失败返回
null,匹配不上任何批准 → 继续拦。这是典型的 fail-closed:出错时倾向于拒绝,而不是放行。
所以完整的三层防御是这样的:
| 层 | 位置 | 作用 | 确定性 |
|---|---|---|---|
| 1 | 提示词 + temperature 0 | 降低选错工具的概率 | 概率性 |
| 2 | 前端意图检测 + 重定向 | 跑偏时把模型拉回来 | 确定性 |
| 3 | 权限闸门(参数指纹 + 一次性批准) | 就算选对了也可能错,最终兜底 | 确定性 |
第一层是"尽量别错",第二三层是"错了也不会出事"。 这个分层思路,比任何一条具体规则都重要。
三层防御总览:
#mermaid-svg-eSFDrFNzzmYEjK88{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-eSFDrFNzzmYEjK88 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-eSFDrFNzzmYEjK88 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-eSFDrFNzzmYEjK88 .error-icon{fill:#552222;}#mermaid-svg-eSFDrFNzzmYEjK88 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-eSFDrFNzzmYEjK88 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-eSFDrFNzzmYEjK88 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-eSFDrFNzzmYEjK88 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-eSFDrFNzzmYEjK88 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-eSFDrFNzzmYEjK88 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-eSFDrFNzzmYEjK88 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-eSFDrFNzzmYEjK88 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-eSFDrFNzzmYEjK88 .marker.cross{stroke:#333333;}#mermaid-svg-eSFDrFNzzmYEjK88 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-eSFDrFNzzmYEjK88 p{margin:0;}#mermaid-svg-eSFDrFNzzmYEjK88 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-eSFDrFNzzmYEjK88 .cluster-label text{fill:#333;}#mermaid-svg-eSFDrFNzzmYEjK88 .cluster-label span{color:#333;}#mermaid-svg-eSFDrFNzzmYEjK88 .cluster-label span p{background-color:transparent;}#mermaid-svg-eSFDrFNzzmYEjK88 .label text,#mermaid-svg-eSFDrFNzzmYEjK88 span{fill:#333;color:#333;}#mermaid-svg-eSFDrFNzzmYEjK88 .node rect,#mermaid-svg-eSFDrFNzzmYEjK88 .node circle,#mermaid-svg-eSFDrFNzzmYEjK88 .node ellipse,#mermaid-svg-eSFDrFNzzmYEjK88 .node polygon,#mermaid-svg-eSFDrFNzzmYEjK88 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-eSFDrFNzzmYEjK88 .rough-node .label text,#mermaid-svg-eSFDrFNzzmYEjK88 .node .label text,#mermaid-svg-eSFDrFNzzmYEjK88 .image-shape .label,#mermaid-svg-eSFDrFNzzmYEjK88 .icon-shape .label{text-anchor:middle;}#mermaid-svg-eSFDrFNzzmYEjK88 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-eSFDrFNzzmYEjK88 .rough-node .label,#mermaid-svg-eSFDrFNzzmYEjK88 .node .label,#mermaid-svg-eSFDrFNzzmYEjK88 .image-shape .label,#mermaid-svg-eSFDrFNzzmYEjK88 .icon-shape .label{text-align:center;}#mermaid-svg-eSFDrFNzzmYEjK88 .node.clickable{cursor:pointer;}#mermaid-svg-eSFDrFNzzmYEjK88 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-eSFDrFNzzmYEjK88 .arrowheadPath{fill:#333333;}#mermaid-svg-eSFDrFNzzmYEjK88 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-eSFDrFNzzmYEjK88 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-eSFDrFNzzmYEjK88 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eSFDrFNzzmYEjK88 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-eSFDrFNzzmYEjK88 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eSFDrFNzzmYEjK88 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-eSFDrFNzzmYEjK88 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-eSFDrFNzzmYEjK88 .cluster text{fill:#333;}#mermaid-svg-eSFDrFNzzmYEjK88 .cluster span{color:#333;}#mermaid-svg-eSFDrFNzzmYEjK88 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-eSFDrFNzzmYEjK88 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-eSFDrFNzzmYEjK88 rect.text{fill:none;stroke-width:0;}#mermaid-svg-eSFDrFNzzmYEjK88 .icon-shape,#mermaid-svg-eSFDrFNzzmYEjK88 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eSFDrFNzzmYEjK88 .icon-shape p,#mermaid-svg-eSFDrFNzzmYEjK88 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-eSFDrFNzzmYEjK88 .icon-shape .label rect,#mermaid-svg-eSFDrFNzzmYEjK88 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eSFDrFNzzmYEjK88 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-eSFDrFNzzmYEjK88 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-eSFDrFNzzmYEjK88 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 第三层: 错了不出事(fail-closed)
第二层: 错了拉回来(确定性)
第一层: 尽量别错(概率性)
temperature = 0
系统提示词硬约束
前端意图检测
重定向补发命令
权限闸门
参数指纹 + 一次性批准
默认拦截写操作
六、异常与并发边界
这类"LLM + 工具执行"的系统,边界情况比普通 CRUD 多得多,我实际处理了这些:
1. 工具执行异常不能炸穿循环。 任何工具抛异常,都在 AgentLoop 里兜底转成干净的结构化错误返回给模型,不把 Java 堆栈喂给 LLM(喂了它只会瞎编):
java
try {
String result = tool.run(call.args());
gate.afterToolExecuted(call, sessionId, userId, result);
return result;
} catch (Exception e) {
log.error("工具执行异常。sessionId={}, tool={}", sessionId, call.name(), e);
return ToolErrors.fail("TOOL_ERROR", "工具执行失败,请稍后重试");
}
2. 写操作失败时批准要"回存"。 批准是一次性消费的,如果业务失败了(success:false),批准已经被消费掉,用户想重试就得重新批准------体验很差。所以 afterToolExecuted 里判断业务失败就把批准放回去;真异常则保持已消费(fail-closed,重试需重新批准)。
3. 步数上限 8 步。 模型调用和工具执行各算一步,超限立即停止并给用户可理解的提示,防止模型陷入"调工具→不满意→再调"的无限循环。而且一次返回多个工具时,预算耗尽的会逐个标记为未执行,不会执行到一半硬中断。
4. 会话保存的乐观锁 + 重试。 同一个 sessionId 上两个请求并发,用版本号 CAS 保存,冲突时重读最新快照、拼上本轮新增消息再存,既不覆盖对方的聊天记录,也不丢自己的回答。重试 3 次仍失败才放弃,并明确告诉用户"本次回答未保存"。
5. 日志脱敏。 工具参数按字段打码(地址、手机号),自由文本里的手机号做打码+截断。更进一步的是源头脱敏 :order-system 的内部读接口只返回打码后的收货信息,Agent 根本拿不到完整地址------日志层自然就安全了。
七、面试/简历亮点
这段代码能提炼出的东西,比"我会调大模型 API"值钱得多:
- "LLM 输出是概率分布,关键路径不能只靠提示词" ------ 这是 AI 工程和传统工程最大的思维差异。能讲清楚这个,说明你不是只调了 API,而是在做可靠性工程。
- 三层防御分层设计:概率层降随机性 → 应用层确定性重定向 → 闸门层 fail-closed。这套分层可以直接迁移到任何 AI 应用。
- 兜底要确定性:用正则和关键词而不是再叫一次 LLM,理由是"用一个概率系统纠正另一个概率系统,只是把不确定性挪了个地方",还顺带省了延迟和成本。
- 写操作防护的可量化指标:146 个 Agent 模块单元测试(含闸门、工具、会话存储、MCP 握手)+ 端到端冒烟验证"内部密钥配对 / 收货信息脱敏 / 写操作闸门"这些单测隔离中间件测不到的协作问题;GitHub Actions 三个并行任务 + 冒烟全绿。
简历上可以这么写(真实、可验证):
为 LLM Agent 设计三层工具调用可靠性方案(temperature 0 + 硬约束提示词 / 前端确定性意图重定向 / 权限闸门参数指纹 + 一次性批准),解决模型将「取消订单」误判为「查单」导致关键路径失效的问题;写操作默认拦截、批准原子抢占、失败 fail-closed,配套 195 个单元测试 + 端到端冒烟。
八、场景题暴击
场景题 1:如果产品要求"取消订单必须一步到位,不要弹批准按钮",你的三层防御要怎么改?
答题要点:不能简单地把 cancel_order 标成 readOnly=true 放行------那等于把闸门拆了。正确的做法是把确定性往下沉 :由前端或服务端做意图识别后,走一条受控的快捷路径 ------比如识别到"取消 + 订单号"时,由服务端直接发起一个带幂等键的取消请求(幂等键保证用户重复点击不会取消两次),并且这个路径依然要过状态机校验(订单状态必须是可取消态)、依然要落 Outbox 退款事件。核心思路是:降低的是"人工点击"这个交互成本,不是降低"写操作要被约束"这个安全等级。可以顺带补充:这就是为什么状态机幂等和 Outbox 比 UI 上的批准按钮更根本。
场景题 2:前端兜底会不会导致"用户说取消,结果取消了两次"?
答题要点:分三层回答。第一,兜底的触发条件是排他的 ------!shouldShowApprove && !isCancelSuccess,也就是模型既没走到批准、也没报告成功时才补发,所以不会和正常路径重叠。第二,就算真的并发发了两次取消,第二层有订单状态机幂等 兜着:已经是 CANCELLED 的订单不可重复取消,第二次会返回业务失败。第三,退款事件走 Transactional Outbox (取消 + 写退款事件同事务落库),配合唯一索引防止重复投递------业务副作用只由 order-system 负责,且只有一份实现。这三层合起来,"重复取消"在数据层面不可能生效。
小结
回到最开始那个问题------LLM 选错工具怎么办?
答案是:别指望它不选错。
提示词要写、温度要降,但这些都只是"降低概率"。真正让系统可靠的,是在关键路径上放确定性规则:前端检测跑偏并重定向,闸门用参数指纹 + 一次性批准守住写操作,出错时 fail-closed。模型负责它的强项(理解自然语言、决定调哪个工具),代码负责它的强项(确定、原子、可测试)。
这条线划清楚了,AI 应用才算从"能演示"走到"能上线"。
项目仓库:https://github.com/maixiaowen111/order-agent-assistant
推荐标签:AI Agent、LLM、DeepSeek、工具调用、Java