前言:封闭告一段落,开始系统复盘:一个生产 Agent 到底怎么跑起来。踩坑、经验和落地,最主要的是用户认可。尽量用几篇文章讲清,这篇文章是系列第2篇:AI 的通病之一,只满足眼前需求、不顾扩展性,最后堆满硬编码和正则。生产 Agent 真正需要的,不是用关键词猜意图,而是产出一份可检查、可向下分发的结构化结果。
用户说:“帮我看看支付服务”。
上一篇把从界面到 Trace 的整条链路跑通了。这篇盯住:先弄清用户到底想干什么。这句话可能在问概念、查状态、翻日志,甚至准备重启。
- Agent 一上来就选工具,容易答非所问;
- 每次都追问,对话又会变得啰嗦。
意图识别要回答的是:结合上下文,用户目标是什么、信息够不够、下一步该直接答、动手做,还是先问清楚。继续用案例 OpsPilot(企业运维助手),技术栈仍是 TypeScript、Zod 和结构化输出。
意图只做路由,别靠关键词
意图层不负责查数据,也不该提前写最终答案。一份结果通常拆成四块:
intent:用户想达成的目标mode:解释、执行、澄清还是拒绝slots:要做事所需的参数(服务名、环境等;没依据就先null)missingSlots:现在还缺什么
// 意图分析结果示例(教学用,字段旁注明含义)
{
intent: 'query_service_logs', // 业务目标:查服务日志
mode: 'execute', // 处理模式:可以直接执行
slots: { // 执行参数(无依据则为 null)
serviceId: 'payment-api', // 服务 id
environment: 'production', // 环境
timeRange: '30m' // 时间窗
},
missingSlots: [] // 必填槽已齐;有缺则 mode 应为 clarify
}
最容易写出的代码大概是:
// 反例:用关键词当主分类器——否定、指代、说明类都会误判
function detectIntent(message: string) { // 反例主路径:关键词分类(勿照抄)
if (message.includes('日志')) return 'query_service_logs' // 为什么错:命中「日志」就把说明类当执行类
if (message.includes('重启')) return 'restart_service' // 为什么错:命中「重启」就把「别重启」当写操作
return 'unknown' // 为什么错:未知只靠关键词,指代/省略永远进不来
}
如果靠它来判断用户什么意思,很快会撞墙:
“日志查询有什么用?” → 在问功能,不是要查日志
“看看 payment 为什么一直报错” → 没有“日志”,却可能需要查日志
“别重启,只告诉我影响” → 出现“重启”,实际禁止执行
“还是看刚才那个吧” → 没有任何业务关键词
同义、否定、指代、省略太多,判断错误。靠堆 if 和正则猜意思,维护成本也会失控。
所以,更稳的分工是:
- 模型和 Prompt:读懂用户在说什么,选出意图,填好服务名、环境这些参数
- 工程代码:核对格式对不对、意图是否在名单里、有没有权限、风险要不要拦,以及真正去执行
哪些事仍然适合写死规则:高风险操作必须确认、敏感信息脱敏、按租户隔离数据、工具名只能走白名单。能不能干,用规则卡;想干什么,交给模型。 代码不去猜用户那句话什么意思,但必须挡住非法、越权或缺字段的结果。
这里反对的是「拿关键词、正则当猜意思的主办法」,不是反对一切不用大模型的方案。认意图可以有两条路,但都不是 includes('日志'):
- 结构化 LLM(本文默认):意图还在变、样本不够多时,让模型按约定格式输出,再由代码检查后往下分
- 微调小分类器:意图标签已经很稳、样本够多、又特别在意延迟和成本时,可以训一个只在固定几个标签里选的分类器,代替每次调大模型——仍然是在名单里选,不是关键词命中
意图边界怎么写,别和工具绑死
很多项目效果不稳,往往是意图名字太像、边界糊在一起,不是模型不够强。下面三个几乎没法分:
query_service
inspect_service
check_service
一个能用的意图定义,至少要有:这段意图是干什么的、几句正例、几句反例、执行前必填哪些参数、以及允许哪些 mode:
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 用两次:
- 调模型时:能走服务商自带的结构化输出 / Function Calling 就走,让它按这个表填。
- 回来之后:代码再用
safeParse验一次——模型照样会漏字段、自己编一个不存在的意图 id。
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,别另起一套字段。
// 组装意图分析 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 干两件事:先问模型,再自己验货。顺序别乱:
- 带上 Schema 和 Prompt 调模型
- 用 Zod 再验一遍字段对不对
intent必须在候选表里——别让模型自己造一个新名字mode必须是这个意图允许的(比如说明类不能直接execute);reject留给明显越界,不用写进每个意图的允许列表
哪一步不过,就返回失败,让上层重试或走兜底。别拿半对半错的结果继续往下跑。
// 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:做不了或不该做——直接回拒绝
一句话:进来的是验过的意图结果,出去的是答、问、做、拒里的一种。
// 对已校验的 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 过关之后往哪走。
缺槽、指代与风险门禁
用户说:“查一下支付服务的错误日志”。
服务和目标已经清楚,环境还不知道。正确做法是只问缺的那一项,不要猜成生产,也不要把整张表单再问一遍。
用户答:“生产”后。
要和上一轮「还差什么」的状态合并,再检查一遍。这份待补状态要有过期时间:隔很久才回一句「生产」,不能默认还在答上一题。
// 待补全状态:只合并缺的槽,过期后作废,避免把「生产」误接到旧问题
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。
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 分发
}
评测、演进与常见坑
评测样本至少要盖住这些情况:正常说法、换一种说法、带否定的句子、说明类和执行类边界、指代、「还差参数」、一次提多个目标、明显越界、以及名字很像容易搞混的意图。(是不是感觉很绕。。。但事实就需要这样)
// 评测用例示例:测指代「它」能否继承上轮服务与环境
{
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,再用贴近真实说法的标注集评测。意图要多少、样本要多少没有统一门槛,看场景盖全了没有、错误主要落在哪。随后再补会话状态、缺参就问、高风险确认,以及误判回流。
小结
上面感觉繁琐,没关系,我们记住小结这里。这篇先立住三条边界:
- 模型负责读懂并按格式吐出意图;代码只负责检查和往下分发。(返璞归真,这句是精髓)
- 别拿关键词、正则去猜用户想干什么。能不能干,用规则卡死;想干什么,交给模型。
- 缺参数就问清楚,高风险就让人点确认。模型自己写的
confidence只能参考,别当唯一开关。
这篇文章有很多的实践,一定要结合“小结”然后多读几遍文章。下一篇继续:《ReAct 循环:从一次 Function Call 到多步 Agent》。
希望这篇文章对你有所帮助、有所借鉴。