前言:封闭告一段落,开始系统复盘:一个生产 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',// 服务 idenvironment:'production',// 环境timeRange:'30m'// 时间窗},missingSlots:[]// 必填槽已齐;有缺则 mode 应为 clarify}最容易写出的代码大概是:
// 反例:用关键词当主分类器——否定、指代、说明类都会误判functiondetectIntent(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:
typeIntentDefinition={// 意图定义:边界/正反例/槽位id:string// 意图唯一 id,路由只认枚举description:string// 给模型看的边界说明positiveExamples:string[]// 正例:什么属于这个意图negativeExamples:string[]// 反例:相邻易混说法requiredSlots:string[]// 执行前必填槽位allowedModes:Array<'answer'|'execute'|'clarify'>// 该意图允许的处理模式}// 意图定义,demo示例如下constintents:IntentDefinition[]=[{id:'explain_log_capability',// 说明类:问能力,不读实时数据description:'询问日志查询能力、用途、限制或用法,不读实时数据',// 边界说明positiveExamples:[// 正例'日志查询能做什么','可以按时间范围筛选日志吗'],negativeExamples:[// 反例:相邻易混'查一下支付服务最近半小时的错误日志'// 这句应归到实时查询],requiredSlots:[],// 说明类通常无必填槽allowedModes:['answer']// 只允许回答,不允许 execute},{id:'query_service_logs',// 执行类:读实时/历史日志description:'读取某个服务的实时或历史日志',// 边界说明positiveExamples:[// 正例'查一下支付服务最近半小时的错误日志','看看刚才那个服务为什么报错'],negativeExamples:[// 反例'日志查询能做什么'// 问能力,不是要查],requiredSlots:['serviceId','environment'],// 缺了就 clarifyallowedModes:['execute','clarify']// 不允许 answer 冒充说明类}]正例告诉模型「什么算这类」,反例把容易搞混的邻居意图划开。
意图是「用户想达成什么」,工具只是眼下怎么做。二者别绑成一对一。比如用户要「查服务健康」:
- 同一件事,可能要先查目录、再看监控,出问题再翻日志——一步意图,多步工具
- 反过来,同一套「查日志」能力,排障要用,审计也可能用——一个工具,多个意图
工具少、步骤固定时,认准意图后直接进对应处理函数就行。流程一变长,就别让意图层既猜目标又排工具顺序:先弄清用户要什么,再决定调哪些工具、按什么顺序。也不要「查日志 + 看监控 + 重启」每组合出一个新意图标签,标签会爆。
系统还简单时,可以先做一个像diagnose_service这样的大包意图。变复杂以后,更稳的是让意图层只说出「要完成哪些目标」,后面再规划「先做啥、失败了怎么办」。
协议、Prompt 与路由代码
意图表写好了,代码这边还要接四步:先定好返回长什么样 → 用 Prompt 让模型按这个填 → 代码再检查一遍 → 再按mode决定是答、追问、动手,还是拒绝。下面四段,一段一步。
1、用 Zod 约定输出形状
模型给的是「我猜用户想干这个」,程序没法拿一段随便写的文字去分支。所以先用 Zod(或同类的 JSON Schema)把允许的字段写死:有哪些键、只能取哪些值、没依据的参数必须是null。
这份 Schema 用两次:
- 调模型时:能走服务商自带的结构化输出 / Function Calling 就走,让它按这个表填。
- 回来之后:代码再用
safeParse验一次——模型照样会漏字段、自己编一个不存在的意图 id。
import{z}from'zod'constIntentResultSchema=z.object({// 意图结构化输出契约// 业务目标:必须落在候选枚举内,禁止模型自创 idintent: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({// 执行参数;无依据时必须为 nullserviceId:z.string().nullable(),// 服务 idenvironment:z// 环境枚举.enum(['development','testing','production'])// 环境枚举.nullable(),// 无依据时为 nulltimeRange:z.string().nullable()// 时间窗,如 30m}),// 仍缺的必填槽;有缺则 mode 应为 clarifymissingSlots:z.array(// 仍缺必填槽z.enum(['serviceId','environment','timeRange'])// 可缺槽枚举),// 澄清话术;非 clarify 时可为 nullclarificationQuestion:z.string().nullable()// 澄清话术})typeIntentResult=z.infer<typeofIntentResultSchema>// 类型定义confidence可以拿来瞅一眼,但别当成可靠概率。模型自己写 0.9,不代表真有 90% 正确率。
2、Prompt 只给边界和上下文
Schema 说「返回长什么样」,Prompt 说「你怎么判」。这里别让它写业务答案,也别让它去调工具——这一步只负责认意图。
Prompt 里一般就放这些:你是干什么的、有哪些候选意图(带正反例)、最近几轮对话和已经确认过的状态(好理解「它」「刚才那个」)、用户这句原话。输出格式指回上面的 Schema,别另起一套字段。
// 组装意图分析 Prompt:任务边界 + 候选定义 + 上下文 + 本轮消息functionbuildIntentPrompt(input:{// 组装意图分析 Promptintents: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 除外)asyncfunctionanalyzeIntent(input:AnalyzeInput){// 调模型拿意图并二次校验constraw=awaitllm.generateStructured({// 优先用原生 structured output / function calling,别只靠「只返回 JSON」schema:IntentResultSchema,// Zod/JSON Schema 契约prompt:buildIntentPrompt(input)// 含候选意图与边界})constparsed=IntentResultSchema.safeParse(raw)// 模型也可能吐非法字段;代码必须再验一遍if(!parsed.success){// Schema 未通过则结构化失败return{// Schema 未过:上游应重试或走兜底,勿直接路由status:'invalid_output'asconst,// Schema 未通过issues:parsed.error.issues// Zod 问题列表}}constdefinition=input.intents.find(item=>item.id===parsed.data.intent)// 禁止模型自创意图 idif(!definition){// 意图 id 不在候选表return{status:'unknown_intent'asconst}// 意图 id 不在候选表}// 说明类不得 execute:mode 必须落在该意图声明的 allowedModes// reject 留给 out_of_scope 等越界,不要求写进每个意图的 allowedModesif(parsed.data.mode!=='reject'&&!definition.allowedModes.includes(parsed.data.mode)){return{status:'invalid_mode'asconst,// mode 与意图定义冲突intent:parsed.data.intent,mode:parsed.data.mode}}return{// 校验通过:可交给门禁 + routeIntentstatus:'success'asconst,// 校验通过result:parsed.data// 可路由的结构化意图}}4、routeIntent:按 mode 分发
到这里,用户那句话已经判完了。routeIntent不再读原话,只看验过的mode,决定接下来干什么:
answer:解释类——查知识、说口径,不动写工具clarify:还缺参数——把要问的那一句交给前端execute:先按登录身份验权限,再去调工具或做规划reject:做不了或不该做——直接回拒绝
一句话:进来的是验过的意图结果,出去的是答、问、做、拒里的一种。
// 对已校验的 mode 做分发——这里不再猜自然语言asyncfunctionrouteIntent(result:IntentResult,context:Context){// 按合法 mode 分发switch(result.mode){// 按已校验 mode 分发;这里不再猜用户那句话case'answer':// 说明类returnanswerKnowledgeQuestion(result,context)// 说明类:查知识/口径,不调写工具case'clarify':// 缺槽澄清return{// 缺槽:只问缺的,不猜环境type:'clarification',// 前端渲染补参卡content:result.clarificationQuestion// 只问缺的那一项}case'execute':// 执行类awaitauthorizeIntent(result.intent,context.auth)// 执行前重新鉴权;身份以 context.auth 为准returnexecuteIntent(result,context)// 进入工具/规划case'reject':// 越界拒绝return{// 越界/不可执行:直接拒绝话术,不调工具type:'rejected',// 路由结果:拒绝content:'当前请求无法执行'// 拒绝话术}}}真上线时,前面通常还要过一道dispatchAfterGate:高风险先让人点确认,别直接execute;缺参数就强制走clarify。routeIntent只管 mode 过关之后往哪走。
缺槽、指代与风险门禁
用户说:“查一下支付服务的错误日志”。
服务和目标已经清楚,环境还不知道。正确做法是只问缺的那一项,不要猜成生产,也不要把整张表单再问一遍。
用户答:“生产”后。
要和上一轮「还差什么」的状态合并,再检查一遍。这份待补状态要有过期时间:隔很久才回一句「生产」,不能默认还在答上一题。
// 待补全状态:只合并缺的槽,过期后作废,避免把「生产」误接到旧问题typePendingSlotFill={intent:string// 待补全的意图 idslots:Record<string,string|null>// 已填槽;未填为 nullmissingSlots:string[]// 仍缺的必填槽名expiresAt:number// 超过则丢弃,要求用户重说完整请求}// 合并用户补参到待补全状态;过期则作废,避免串接到旧题functionmergeSlotAnswer(pending:PendingSlotFill,// 待补全状态answer:{environment?:string},// 用户本轮补参now=Date.now()// 可注入时钟,便于单测过期分支){if(now>pending.expiresAt){// 过期:不能把「生产」误接到很久以前的问题return{status:'expired'asconst}// 过期:要求用户重说}constslots={...pending.slots,...answer}// 只覆盖本轮给出的槽// 仍缺的必填槽;有缺则 mode 继续 clarifyconstmissingSlots=pending.missingSlots.filter(key=>!slots[key])return{// 合并成功:按是否仍缺槽决定 clarify/executestatus:'ok'asconst,// 合并成功result:{intent:pending.intent,// 继承待补全意图mode:missingSlots.length?'clarify':'execute',// 仍缺则继续澄清slots,// 合并后的槽位missingSlots// 仍缺的槽}}}指代靠结构化状态,不靠全文塞入
用户说「它」「刚才那个」时,确实需要上下文,但别把整段聊天记录一股脑塞给模型。
通常够用的是:最近几轮原文、最近一次成功操作记下的对象、当前还在补的意图、用户明确确认过的状态。只有工具真的成功了,或用户明确选过了,才更新currentService;查找失败时,别把模型猜的服务名当成已经确认的事实。
风险优先于置信度
模型不太有把握,也不等于必须追问。confidence < 0.8就一律确认,会产生大量没必要的打扰。更合理的是同时看风险和能不能回滚:
- 缺必填参数就问清楚
- 高风险就让人确认
- 只有置信度低到离谱再追问
- 其余继续
读公开信息可以多一点不确定;但是,删数据、发版本就算意图很明确,也要再确认一次,并再验一遍权限。
confirm不是mode里的一个取值,而是往下分发前多拦的一道门:高风险先弹出确认(HITL,人在回路),用户点同意并重新验权限后,才允许execute。缺参数就把mode改成clarify,再进routeIntent。
typeGate='clarify'|'confirm'|'continue'// 门禁顺序:缺槽 > 高风险 > 极低置信度;不要把 confidence 当唯一开关// 0.35 是作者建议的起点,须用自家评测集校准functiondecideGate(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,不假装成 modeasyncfunctiondispatchAfterGate(result:IntentResult,risk:'low'|'high',context:Context){constgate=decideGate(result,risk)if(gate==='confirm'){return{type:'interrupt'asconst,// 与 RunEvent.interrupt / HITL 对齐interruptType:'confirm_risky',reason:'高风险操作需确认',payload:{intent:result.intent,slots:result.slots}// 确认卡展示提案,不直接执行}}if(gate==='clarify'){// 强制走澄清分支,避免缺槽仍 executereturnrouteIntent({...result,mode:'clarify'},context)}returnrouteIntent(result,context)// continue:按原 mode 分发}评测、演进与常见坑
评测样本至少要盖住这些情况:正常说法、换一种说法、带否定的句子、说明类和执行类边界、指代、「还差参数」、一次提多个目标、明显越界、以及名字很像容易搞混的意图。(是不是感觉很绕。。。但事实就需要这样)
// 评测用例示例:测指代「它」能否继承上轮服务与环境{id:'intent-017',// 用例 idmessages:[// 多轮输入{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》。
希望这篇文章对你有所帮助、有所借鉴。