LLM 选错工具怎么办?提示词之外还需要确定性规则兜底

一、业务背景:一个"偶尔抽风"的 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" 这句话,模型完全可以合理地解释成两步:

  1. 先查一下这个订单存不存在、是不是已支付(query_order)
  2. 再决定要不要取消

这是教科书式的"合理"行为,很多 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)
}

这段代码有三个我特意处理的点:

  1. 中间那轮不展示给用户。 模型跑偏的那次回答直接被丢弃,用户看不到"我先帮您查一下"这种让人困惑的中间态。对话是干净的。
  2. 复用同一个 sessionId。 补发的命令走的是同一个服务端会话,多轮记忆连续,模型能看到自己刚才做了什么,不会重新开始。
  3. 补发命令本身也是"人话"。 它不是直接调接口去取消订单------那样就绕过了模型,也绕过了闸门。它只是把用户的意图再说清楚一遍,剩下的决策权还在模型手里,闸门依然拦得住。这条边界很重要:兜底是为了让模型回到正确路径,不是为了替模型做决策。

第二层保险的完整流程:
#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"值钱得多:

  1. "LLM 输出是概率分布,关键路径不能只靠提示词" ------ 这是 AI 工程和传统工程最大的思维差异。能讲清楚这个,说明你不是只调了 API,而是在做可靠性工程。
  2. 三层防御分层设计:概率层降随机性 → 应用层确定性重定向 → 闸门层 fail-closed。这套分层可以直接迁移到任何 AI 应用。
  3. 兜底要确定性:用正则和关键词而不是再叫一次 LLM,理由是"用一个概率系统纠正另一个概率系统,只是把不确定性挪了个地方",还顺带省了延迟和成本。
  4. 写操作防护的可量化指标: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

相关推荐
虎鲸不是鱼1 小时前
【虎鲸进化论 07】给 AI 装上手和脚:MCP 协议与“给 Agent 用的工具“设计
人工智能·自动化·大模型·llm·自进化agent
今天的砖头有点烫手啊1 小时前
Java 序列化到底怎么选?从 Serializable 到 Protobuf 的演进与选型
java·json
invicinble1 小时前
提示词设计与维特根斯坦哲学
人工智能
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 登录发送验证码的测试验证与经验复盘
java·开发语言·交友
MiYi124061 小时前
2026深度解读:Work Agent如何拆解复杂需求并执行长程任务
人工智能
镜象科技1 小时前
企业AI心理健康服务怎么选?需求动因、三类路线与选型方法
人工智能
亚远景aspice1 小时前
亚远景科技受邀出席随州2026世界标准日培训会:建言汽车出口认证与研发能力重塑
人工智能·科技·aspice·过程改进·亚远景科技·过程能力评估·aspice评估
夕除1 小时前
redis--主从还是集群,内存
java·spring·mybatis
百数平台1 小时前
低代码安全技术实践:表单 / 流程 / 报表三级权限体系设计解析
java·大数据·数据库