【封闭结束2】Agent 意图识别:从语义理解到稳定路由

最近一段时间封闭结束,开始系统性整理。这篇文章是系列第二篇:AI问题之一是只管实现当前用户需求,不考虑扩展性,所以会出现很多的硬编码和正则。所以Agent实际中需要的是别再用关键词猜用户想干什么,而是改成一份能检查、能向下分发的结构化结果。

用户说:"帮我看看支付服务"。

上一篇把从界面到 Trace 的整条链路跑通了。这篇盯住:先弄清用户到底想干什么。这句话可能在问概念、查状态、翻日志,甚至准备重启。

  • Agent 一上来就选工具,容易答非所问;
  • 每次都追问,对话又会变得啰嗦。

意图识别要回答的是:结合上下文,用户目标是什么、信息够不够、下一步该直接答、动手做,还是先问清楚。继续用案例 OpsPilot(企业运维助手),技术栈仍是 TypeScript、Zod 和结构化输出。

意图只做路由,别靠关键词

意图层不负责查数据,也不该提前写最终答案。一份结果通常拆成四块:

  • intent:用户想达成的目标
  • mode:解释、执行、澄清还是拒绝
  • slots:要做事所需的参数(服务名、环境等;没依据就先 null)
  • missingSlots:现在还缺什么
ts 复制代码
// 意图分析结果示例(教学用,字段旁注明含义)
{
  intent: 'query_service_logs', // 业务目标:查服务日志
  mode: 'execute',              // 处理模式:可以直接执行
  slots: { // 执行参数(无依据则为 null)
    serviceId: 'payment-api',   // 服务 id
    environment: 'production',  // 环境
    timeRange: '30m'            // 时间窗
  },
  missingSlots: []              // 必填槽已齐;有缺则 mode 应为 clarify
}

最容易写出的代码大概是:

ts 复制代码
// 反例:用关键词当主分类器------否定、指代、说明类都会误判
function detectIntent(message: string) { // 反例主路径:关键词分类(勿照抄)
  if (message.includes('日志')) return 'query_service_logs' // 为什么错:命中「日志」就把说明类当执行类
  if (message.includes('重启')) return 'restart_service' // 为什么错:命中「重启」就把「别重启」当写操作
  return 'unknown' // 为什么错:未知只靠关键词,指代/省略永远进不来
}

如果靠它来判断用户什么意思,很快会撞墙:

text 复制代码
"日志查询有什么用?"          → 在问功能,不是要查日志
"看看 payment 为什么一直报错" → 没有"日志",却可能需要查日志
"别重启,只告诉我影响"         → 出现"重启",实际禁止执行
"还是看刚才那个吧"             → 没有任何业务关键词

同义、否定、指代、省略太多,判断错误。靠堆 if 和正则猜意思,维护成本也会失控。

所以,更稳的分工是:

  • 模型和 Prompt:读懂用户在说什么,选出意图,填好服务名、环境这些参数
  • 工程代码:核对格式对不对、意图是否在名单里、有没有权限、风险要不要拦,以及真正去执行

哪些事仍然适合写死规则:高风险操作必须确认、敏感信息脱敏、按租户隔离数据、工具名只能走白名单。能不能干,用规则卡;想干什么,交给模型。 代码不去猜用户那句话什么意思,但必须挡住非法、越权或缺字段的结果。

这里反对的是「拿关键词、正则当猜意思的主办法」,不是反对一切不用大模型的方案。认意图可以有两条路,但都不是 includes('日志'):

  • 结构化 LLM(本文默认):意图还在变、样本不够多时,让模型按约定格式输出,再由代码检查后往下分
  • 微调小分类器:意图标签已经很稳、样本够多、又特别在意延迟和成本时,可以训一个只在固定几个标签里选的分类器,代替每次调大模型------仍然是在名单里选,不是关键词命中

意图边界怎么写,别和工具绑死

很多项目效果不稳,往往是意图名字太像、边界糊在一起,不是模型不够强。下面三个几乎没法分:

text 复制代码
query_service
inspect_service
check_service

一个能用的意图定义,至少要有:这段意图是干什么的、几句正例、几句反例、执行前必填哪些参数、以及允许哪些 mode:

ts 复制代码
type IntentDefinition = { // 意图定义:边界/正反例/槽位
  id: string                                    // 意图唯一 id,路由只认枚举
  description: string                           // 给模型看的边界说明
  positiveExamples: string[]                    // 正例:什么属于这个意图
  negativeExamples: string[]                    // 反例:相邻易混说法
  requiredSlots: string[]                       // 执行前必填槽位
  allowedModes: Array<'answer' | 'execute' | 'clarify'> // 该意图允许的处理模式
}

// 意图定义,demo示例如下
const intents: IntentDefinition[] = [
  {
    id: 'explain_log_capability', // 说明类:问能力,不读实时数据
    description: '询问日志查询能力、用途、限制或用法,不读实时数据', // 边界说明
    positiveExamples: [ // 正例
      '日志查询能做什么',
      '可以按时间范围筛选日志吗'
    ],
    negativeExamples: [ // 反例:相邻易混
      '查一下支付服务最近半小时的错误日志' // 这句应归到实时查询
    ],
    requiredSlots: [],              // 说明类通常无必填槽
    allowedModes: ['answer']        // 只允许回答,不允许 execute
  },
  {
    id: 'query_service_logs',       // 执行类:读实时/历史日志
    description: '读取某个服务的实时或历史日志', // 边界说明
    positiveExamples: [ // 正例
      '查一下支付服务最近半小时的错误日志',
      '看看刚才那个服务为什么报错'
    ],
    negativeExamples: [ // 反例
      '日志查询能做什么'             // 问能力,不是要查
    ],
    requiredSlots: ['serviceId', 'environment'], // 缺了就 clarify
    allowedModes: ['execute', 'clarify'] // 不允许 answer 冒充说明类
  }
]

正例告诉模型「什么算这类」,反例把容易搞混的邻居意图划开。

意图是「用户想达成什么」,工具只是眼下怎么做。二者别绑成一对一。比如用户要「查服务健康」:

  • 同一件事,可能要先查目录、再看监控,出问题再翻日志------一步意图,多步工具
  • 反过来,同一套「查日志」能力,排障要用,审计也可能用------一个工具,多个意图

工具少、步骤固定时,认准意图后直接进对应处理函数就行。流程一变长,就别让意图层既猜目标又排工具顺序:先弄清用户要什么,再决定调哪些工具、按什么顺序。也不要「查日志 + 看监控 + 重启」每组合出一个新意图标签,标签会爆。

系统还简单时,可以先做一个像 diagnose_service 这样的大包意图。变复杂以后,更稳的是让意图层只说出「要完成哪些目标」,后面再规划「先做啥、失败了怎么办」。

协议、Prompt 与路由代码

意图表写好了,代码这边还要接四步:先定好返回长什么样 → 用 Prompt 让模型按这个填 → 代码再检查一遍 → 再按 mode 决定是答、追问、动手,还是拒绝。下面四段,一段一步。

1、用 Zod 约定输出形状

模型给的是「我猜用户想干这个」,程序没法拿一段随便写的文字去分支。所以先用 Zod(或同类的 JSON Schema)把允许的字段写死:有哪些键、只能取哪些值、没依据的参数必须是 null。

这份 Schema 用两次:

  1. 调模型时:能走服务商自带的结构化输出 / Function Calling 就走,让它按这个表填。
  2. 回来之后 :代码再用 safeParse 验一次------模型照样会漏字段、自己编一个不存在的意图 id。
ts 复制代码
import { z } from 'zod'

const IntentResultSchema = z.object({ // 意图结构化输出契约
  // 业务目标:必须落在候选枚举内,禁止模型自创 id
  intent: z.enum([ // 业务目标枚举,禁自创 id
    'explain_log_capability',
    'query_service_logs',
    'query_service_status',
    'restart_service',
    'out_of_scope'
  ]),
  // 处理模式:说明 / 执行 / 澄清 / 拒绝
  mode: z.enum(['answer', 'execute', 'clarify', 'reject']), // 处理模式枚举
  // 仅供观测,不能当可靠概率门禁
  confidence: z.number().min(0).max(1), // 仅供观测,不作可靠门禁
  // 执行参数;无依据时必须为 null,禁止猜测
  slots: z.object({ // 执行参数;无依据时必须为 null
    serviceId: z.string().nullable(), // 服务 id
    environment: z // 环境枚举
      .enum(['development', 'testing', 'production']) // 环境枚举
      .nullable(),                   // 无依据时为 null
    timeRange: z.string().nullable() // 时间窗,如 30m
  }),
  // 仍缺的必填槽;有缺则 mode 应为 clarify
  missingSlots: z.array( // 仍缺必填槽
    z.enum(['serviceId', 'environment', 'timeRange']) // 可缺槽枚举
  ),
  // 澄清话术;非 clarify 时可为 null
  clarificationQuestion: z.string().nullable() // 澄清话术
})

type IntentResult = z.infer<typeof IntentResultSchema> // 类型定义

confidence 可以拿来瞅一眼,但别当成可靠概率。模型自己写 0.9,不代表真有 90% 正确率。

2、Prompt 只给边界和上下文

Schema 说「返回长什么样」,Prompt 说「你怎么判」。这里别让它写业务答案,也别让它去调工具------这一步只负责认意图。

Prompt 里一般就放这些:你是干什么的、有哪些候选意图(带正反例)、最近几轮对话和已经确认过的状态(好理解「它」「刚才那个」)、用户这句原话。输出格式指回上面的 Schema,别另起一套字段。

ts 复制代码
// 组装意图分析 Prompt:任务边界 + 候选定义 + 上下文 + 本轮消息
function buildIntentPrompt(input: { // 组装意图分析 Prompt
  intents: IntentDefinition[] // 候选意图(含正反例)
  recentMessages: Array<{ role: 'user' | 'assistant'; content: string }> // 最近几轮,供指代
  state: Record<string, unknown> // 已确认结构化状态
  message: string               // 本轮用户话
}) {
  // 返回给 llm.generateStructured 的提示词正文(非执行逻辑)
  return `
你是对话 Agent 的意图分析器,只负责理解用户目标和缺失信息,
不回答业务问题,不执行任何工具。

请遵守:
1. 结合本轮消息、最近对话和已确认状态判断意图。
2. 不要因为出现某个关键词就直接分类,要理解否定、指代和省略。
3. 用户只是询问功能或概念时,选择说明类意图,不得选择执行模式。
4. 执行所需必填槽位缺失时,mode 必须为 clarify。
5. 不确定或超出候选范围时选择 out_of_scope,不要创造新意图 id。
6. slots 只能填写输入或上下文中有依据的值,不得猜测。

候选意图:
${JSON.stringify(input.intents, null, 2)}

最近对话:
${JSON.stringify(input.recentMessages, null, 2)}

已确认状态:
${JSON.stringify(input.state, null, 2)}

本轮用户消息:
${input.message}

只返回符合约定 Schema 的 JSON。
`
}

如果模型服务本身支持 JSON Schema 或 Function Calling,优先用它自带的结构化输出,别只靠 Prompt 里写一句「只返回 JSON」。

3、analyzeIntent:解析与校验

analyzeIntent 干两件事:先问模型,再自己验货。顺序别乱:

  1. 带上 Schema 和 Prompt 调模型
  2. 用 Zod 再验一遍字段对不对
  3. intent 必须在候选表里------别让模型自己造一个新名字
  4. mode 必须是这个意图允许的(比如说明类不能直接 execute);reject 留给明显越界,不用写进每个意图的允许列表

哪一步不过,就返回失败,让上层重试或走兜底。别拿半对半错的结果继续往下跑。

ts 复制代码
// 1) 调模型  2) Zod  3) 意图 id 在候选表  4) mode 落在该意图 allowedModes(reject 除外)
async function analyzeIntent(input: AnalyzeInput) { // 调模型拿意图并二次校验
  const raw = await llm.generateStructured({ // 优先用原生 structured output / function calling,别只靠「只返回 JSON」
    schema: IntentResultSchema, // Zod/JSON Schema 契约
    prompt: buildIntentPrompt(input) // 含候选意图与边界
  })

  const parsed = IntentResultSchema.safeParse(raw) // 模型也可能吐非法字段;代码必须再验一遍
  if (!parsed.success) { // Schema 未通过则结构化失败
    return { // Schema 未过:上游应重试或走兜底,勿直接路由
      status: 'invalid_output' as const, // Schema 未通过
      issues: parsed.error.issues // Zod 问题列表
    }
  }

  const definition = input.intents.find(item => item.id === parsed.data.intent) // 禁止模型自创意图 id
  if (!definition) { // 意图 id 不在候选表
    return { status: 'unknown_intent' as const } // 意图 id 不在候选表
  }

  // 说明类不得 execute:mode 必须落在该意图声明的 allowedModes
  // reject 留给 out_of_scope 等越界,不要求写进每个意图的 allowedModes
  if (
    parsed.data.mode !== 'reject' &&
    !definition.allowedModes.includes(parsed.data.mode)
  ) {
    return {
      status: 'invalid_mode' as const, // mode 与意图定义冲突
      intent: parsed.data.intent,
      mode: parsed.data.mode
    }
  }

  return { // 校验通过:可交给门禁 + routeIntent
    status: 'success' as const, // 校验通过
    result: parsed.data // 可路由的结构化意图
  }
}

4、routeIntent:按 mode 分发

到这里,用户那句话已经判完了。routeIntent 不再读原话 ,只看验过的 mode,决定接下来干什么:

  • answer:解释类------查知识、说口径,不动写工具
  • clarify:还缺参数------把要问的那一句交给前端
  • execute:先按登录身份验权限,再去调工具或做规划
  • reject:做不了或不该做------直接回拒绝

一句话:进来的是验过的意图结果,出去的是答、问、做、拒里的一种。

ts 复制代码
// 对已校验的 mode 做分发------这里不再猜自然语言
async function routeIntent(result: IntentResult, context: Context) { // 按合法 mode 分发
  switch (result.mode) { // 按已校验 mode 分发;这里不再猜用户那句话
    case 'answer': // 说明类
      return answerKnowledgeQuestion(result, context) // 说明类:查知识/口径,不调写工具
    case 'clarify': // 缺槽澄清
      return { // 缺槽:只问缺的,不猜环境
        type: 'clarification', // 前端渲染补参卡
        content: result.clarificationQuestion // 只问缺的那一项
      }
    case 'execute': // 执行类
      await authorizeIntent(result.intent, context.auth) // 执行前重新鉴权;身份以 context.auth 为准
      return executeIntent(result, context) // 进入工具/规划
    case 'reject': // 越界拒绝
      return { // 越界/不可执行:直接拒绝话术,不调工具
        type: 'rejected', // 路由结果:拒绝
        content: '当前请求无法执行' // 拒绝话术
      }
  }
}

真上线时,前面通常还要过一道 dispatchAfterGate:高风险先让人点确认,别直接 execute;缺参数就强制走 clarify。routeIntent 只管 mode 过关之后往哪走。

缺槽、指代与风险门禁

用户说:"查一下支付服务的错误日志"。

服务和目标已经清楚,环境还不知道。正确做法是只问缺的那一项,不要猜成生产,也不要把整张表单再问一遍。

用户答:"生产"后。

要和上一轮「还差什么」的状态合并,再检查一遍。这份待补状态要有过期时间:隔很久才回一句「生产」,不能默认还在答上一题。

ts 复制代码
// 待补全状态:只合并缺的槽,过期后作废,避免把「生产」误接到旧问题
type PendingSlotFill = {
  intent: string                         // 待补全的意图 id
  slots: Record<string, string | null>   // 已填槽;未填为 null
  missingSlots: string[]                 // 仍缺的必填槽名
  expiresAt: number                      // 超过则丢弃,要求用户重说完整请求
}

// 合并用户补参到待补全状态;过期则作废,避免串接到旧题
function mergeSlotAnswer(
  pending: PendingSlotFill, // 待补全状态
  answer: { environment?: string }, // 用户本轮补参
  now = Date.now() // 可注入时钟,便于单测过期分支
) {
  if (now > pending.expiresAt) { // 过期:不能把「生产」误接到很久以前的问题
    return { status: 'expired' as const } // 过期:要求用户重说
  }
  const slots = { ...pending.slots, ...answer } // 只覆盖本轮给出的槽
  // 仍缺的必填槽;有缺则 mode 继续 clarify
  const missingSlots = pending.missingSlots.filter(key => !slots[key])
  return { // 合并成功:按是否仍缺槽决定 clarify/execute
    status: 'ok' as const, // 合并成功
    result: {
      intent: pending.intent, // 继承待补全意图
      mode: missingSlots.length ? 'clarify' : 'execute', // 仍缺则继续澄清
      slots, // 合并后的槽位
      missingSlots // 仍缺的槽
    }
  }
}

指代靠结构化状态,不靠全文塞入

用户说「它」「刚才那个」时,确实需要上下文,但别把整段聊天记录一股脑塞给模型。

通常够用的是:最近几轮原文、最近一次成功操作记下的对象、当前还在补的意图、用户明确确认过的状态。只有工具真的成功了,或用户明确选过了,才更新 currentService;查找失败时,别把模型猜的服务名当成已经确认的事实。

风险优先于置信度

模型不太有把握,也不等于必须追问。confidence < 0.8 就一律确认,会产生大量没必要的打扰。更合理的是同时看风险和能不能回滚:

  • 缺必填参数就问清楚
  • 高风险就让人确认
  • 只有置信度低到离谱再追问
  • 其余继续

读公开信息可以多一点不确定;但是,删数据、发版本就算意图很明确,也要再确认一次,并再验一遍权限。

confirm 不是 mode 里的一个取值,而是往下分发前多拦的一道门:高风险先弹出确认(HITL,人在回路),用户点同意并重新验权限后,才允许 execute。缺参数就把 mode 改成 clarify,再进 routeIntent。

ts 复制代码
type Gate = 'clarify' | 'confirm' | 'continue'

// 门禁顺序:缺槽 > 高风险 > 极低置信度;不要把 confidence 当唯一开关
// 0.35 是作者建议的起点,须用自家评测集校准
function decideGate(result: IntentResult, risk: 'low' | 'high'): Gate {
  if (result.missingSlots.length > 0) return 'clarify' // 缺必填槽:先澄清,禁止猜环境/服务
  if (risk === 'high') return 'confirm' // 高风险:即便意图明确也要二次确认
  if (result.confidence < 0.35) return 'clarify' // 极低置信度才追问;勿用 0.8 一类高阈制造无效交互
  return 'continue' // 低风险且槽齐:直接往下走
}

// 门禁 → 路由:confirm 进 HITL,不假装成 mode
async function dispatchAfterGate(
  result: IntentResult,
  risk: 'low' | 'high',
  context: Context
) {
  const gate = decideGate(result, risk)
  if (gate === 'confirm') {
    return {
      type: 'interrupt' as const, // 与 RunEvent.interrupt / HITL 对齐
      interruptType: 'confirm_risky',
      reason: '高风险操作需确认',
      payload: { intent: result.intent, slots: result.slots } // 确认卡展示提案,不直接执行
    }
  }
  if (gate === 'clarify') {
    // 强制走澄清分支,避免缺槽仍 execute
    return routeIntent({ ...result, mode: 'clarify' }, context)
  }
  return routeIntent(result, context) // continue:按原 mode 分发
}

评测、演进与常见坑

评测样本至少要盖住这些情况:正常说法、换一种说法、带否定的句子、说明类和执行类边界、指代、「还差参数」、一次提多个目标、明显越界、以及名字很像容易搞混的意图。(是不是感觉很绕。。。但事实就需要这样)

ts 复制代码
// 评测用例示例:测指代「它」能否继承上轮服务与环境
{
  id: 'intent-017',            // 用例 id
  messages: [                  // 多轮输入
    { role: 'user', content: '查一下生产环境的 payment-api' }, // 首轮:点名服务与环境
    { role: 'assistant', content: '服务当前运行正常' }, // 上轮助手回复
    { role: 'user', content: '它最近有错误日志吗' } // 「它」= payment-api
  ],
  expected: {                  // 期望结构化结果
    intent: 'query_service_logs', // 应识别为查日志
    mode: 'execute', // 槽齐则可执行
    slots: { // 「它」应对上上轮的服务与环境
      serviceId: 'payment-api',   // 应从上文继承
      environment: 'production'   // 应从上文继承
    }
  }
}

指标怎么拆

分开算:意图对不对、mode 对不对、参数填对没有、该不该追问、有没有误执行、澄清是不是只问真缺的那一项。

只看总体准确率会骗人:样本九成是查询时,模型全猜查询也能拿高分。要看每个意图各自答对了多少、常和谁搞混。

线上误判怎么回修

线上判错了,按这个顺序查:

  • 是不是缺上下文?
  • 意图定义是不是重叠?
  • 正反例有没有盖住这种说法?
  • 模型是不是吐了非法字段?
  • 参数有没有依据?
  • 路由或工具有没有用错结果?

找到第一处错,只改那一层。比如「日志查询有什么用」被当成实时查询时:别在代码里加 includes('有什么用')。给说明类补定义和正例,给实时查询加反例,再补几条相似说法的回归用例,并确认真查询没有被误伤。

意图不到十个时,一次模型调用就能既分类又填参数。意图变多以后,可以先认大类(比如「日志」还是「发布」),再在大类里选具体意图------两层都还是模型对着候选名单按格式输出,不是拿关键词去撞。

再往后可以逐步加上:(版本很重要,比如说SSE中断,用户继续执行,你用哪个版本)

  • 意图定义可配置、可版本化;
  • Prompt、模型和意图版本写进 Trace;
  • 按领域维护评测集;
  • 灰度发布和回滚;

把高频未知请求聚一类,判断是不是真缺新能力。看到一个没见过的说法就新增意图,通常不对。新意图应该代表新目标,不是旧目标换了一种说法。

容易踩的坑

  • 关键词命中就当意图:否定、指代和真实目标都说不清。
  • 标签越细越好:边界重叠、样本不够;只有后面处理方式真的不同,才值得拆。
  • 模型吐什么就执行什么:意图 id、mode、参数都要过 Schema、白名单、鉴权和业务规则。
  • 只看单轮:"继续查它",只能结合历史和已确认状态理解。
  • 缺参数直接猜:少问一次,却可能把查询做成误操作。
  • 用大段业务代码去"补救 "模型输出:越猜用户那句话什么意思,越难维护;应回到意图定义、上下文和评测集。

落地时不必一次上齐。可以先从少量、彼此分得开的核心意图开始,写清定义、正反例和必填参数,接上结构化输出与 Zod,再用贴近真实说法的标注集评测。意图要多少、样本要多少没有统一门槛,看场景盖全了没有、错误主要落在哪。随后再补会话状态、缺参就问、高风险确认,以及误判回流。

小结

上面感觉繁琐,没关系,我们记住小结这里。这篇先立住三条边界:

  1. 模型负责读懂并按格式吐出意图;代码只负责检查和往下分发。(返璞归真,这句是精髓)
  2. 别拿关键词、正则去猜用户想干什么。能不能干,用规则卡死;想干什么,交给模型。
  3. 缺参数就问清楚,高风险就让人点确认。模型自己写的 confidence 只能参考,别当唯一开关。

这篇文章有很多的实践,一定要结合"小结"然后多读几遍文章。下一篇继续:《ReAct 循环:从一次 Function Call 到多步 Agent》。

希望这篇文章对你有所帮助、有所借鉴。

相关推荐
三掌柜6661 小时前
04|鸿蒙7·端侧AI实战:检索增强,把证据带进回答
人工智能·华为·harmonyos
Akamai中国1 小时前
PHP 爆出两大高风险漏洞!Akamai 即时部署防护机制
开发语言·人工智能·云计算·云服务
lucas_AI1 小时前
KV 缓存压到 2.26 比特性能不掉:腾讯把「压缩算法教科书」搬进了大模型推理
人工智能
xiezhr1 小时前
你的WorkBuddy不好用,大概率是这些设置没设好
llm·aigc·agent
YuanDaima20481 小时前
系统架构与数据库理论基础:PostgreSQL 与 pgvector
数据库·人工智能·docker·postgresql·系统架构·产品经理
Sailing1 小时前
【封闭结束1】端到端交付:Agent 一次请求如何走完全链路
人工智能·面试·agent
Solange__1 小时前
AI材质生成、图片贴图和材质扫描怎么选?按5类3D资产比较PBR、可编辑与授权
人工智能·3d·ai·自然语言处理·材质·贴图·游戏策划
高洁011 小时前
边缘AI与大模型轻量化:让智能跑进每一个终端
人工智能·python·深度学习·机器学习·迁移学习
lzhdim8 小时前
AI的分类
人工智能·算法·机器学习·分类·数据挖掘