跪拜 Guibai
← Back to the summary

Production Agent Intent Recognition: Route on Validated Structure, Not Keywords

[End of Closed Phase 2] Agent Intent Recognition: From Semantic Understanding to Stable Routing

Preface: The closed phase has come to a temporary end, and it's time for a systematic retrospective: how a production Agent actually gets running. Pitfalls, experience, and landing it; the most important thing is user recognition. I'll try to explain it across a few articles. This is article 2 of the series: one of AI's common flaws is satisfying only the immediate need without considering extensibility, ending up full of hardcoded logic and regex. What a production Agent really needs is not guessing intent with keywords, but producing a structured result that can be checked and dispatched downstream.

User says: “Help me look at the payment service.”

The previous article got the whole chain from the interface to Trace running. This one focuses on: first figure out what the user actually wants to do. This sentence could be asking about a concept, checking status, browsing logs, or even preparing to restart.

Intent recognition needs to answer: with context, what is the user's goal, is there enough information, and should the next step be answering directly, taking action, or asking first. Continuing with the case study OpsPilot (enterprise operations assistant), the stack is still TypeScript, Zod, and structured output.

Intent is only for routing, don't rely on keywords

The intent layer is not responsible for querying data, nor should it write the final answer ahead of time. A result is usually split into four parts:

// Example intent analysis result (teaching use; meaning noted next to fields)
{
  intent: 'query_service_logs', // business goal: query service logs
  mode: 'execute',              // processing mode: can execute directly
  slots: { // execution parameters (null if no evidence)
    serviceId: 'payment-api',   // service id
    environment: 'production',  // environment
    timeRange: '30m'            // time window
  },
  missingSlots: []              // required slots are complete; if any are missing, mode should be clarify
}

The easiest code to write is probably:

// Anti-example: using keywords as the primary classifier — negation, anaphora, and explanation cases all get misjudged
function detectIntent(message: string) { // anti-example main path: keyword classification (do not copy)
  if (message.includes('logs')) return 'query_service_logs' // why wrong: hitting “logs” treats an explanation case as an execution case
  if (message.includes('restart')) return 'restart_service' // why wrong: hitting “restart” treats “don't restart” as a write operation
  return 'unknown' // why wrong: unknown relies only on keywords; anaphora and ellipsis can never get in
}

If you rely on this to judge what the user means, you will hit a wall quickly:

“What is log query used for?”          → asking about functionality, not querying logs
“Look at why payment keeps erroring”   → no “logs”, but may need logs
“Don't restart, just tell me the impact” → contains “restart”, but actually forbids execution
“Still look at the one from just now”  → no business keyword at all

Synonyms, negation, anaphora, and ellipsis are too varied; judgments go wrong. Piling up ifs and regex to guess meaning also makes maintenance costs spiral out of control.

So a more stable division of labor is:

What is still suitable for hardcoded rules: high-risk operations must be confirmed, sensitive information must be masked, data isolated by tenant, tool names only through a whitelist. Whether something can be done is checked by rules; what the user wants is left to the model. Code does not guess what the user's sentence means, but it must block results that are illegal, unauthorized, or missing fields.

What is opposed here is “using keywords and regex as the primary method for guessing meaning,” not opposing all approaches that don't use LLMs. There are two paths for recognizing intent, but neither is includes('logs'):

How to write intent boundaries, don't bind intent to tools

Many projects have unstable results often because intent names are too similar and boundaries blur together, not because the model isn't strong enough. The following three are almost impossible to distinguish:

query_service
inspect_service
check_service

A usable intent definition should at least have: what this intent does, a few positive examples, a few negative examples, which parameters must be filled before execution, and which modes are allowed:

type IntentDefinition = { // intent definition: boundaries / positive and negative examples / slots
  id: string                                    // unique intent id, routing only recognizes the enum
  description: string                           // boundary description shown to the model
  positiveExamples: string[]                    // positive examples: what belongs to this intent
  negativeExamples: string[]                    // negative examples: adjacent easily confused phrasing
  requiredSlots: string[]                       // slots that must be filled before execution
  allowedModes: Array<'answer' | 'execute' | 'clarify'> // processing modes allowed for this intent
}

// Intent definitions, demo example below
const intents: IntentDefinition[] = [
  {
    id: 'explain_log_capability', // explanation type: asks about capability, does not read live data
    description: 'Ask about log query capability, purpose, limits, or usage; does not read live data', // boundary description
    positiveExamples: [ // positive examples
      'What can log query do',
      'Can logs be filtered by time range'
    ],
    negativeExamples: [ // negative examples: adjacent easily confused
      'Check the error logs of the payment service from the last half hour' // this should go to live query
    ],
    requiredSlots: [],              // explanation type usually has no required slots
    allowedModes: ['answer']        // only answer allowed, execute not allowed
  },
  {
    id: 'query_service_logs',       // execution type: read live/historical logs
    description: 'Read live or historical logs for a service', // boundary description
    positiveExamples: [ // positive examples
      'Check the error logs of the payment service from the last half hour',
      'Look at why the service from just now is erroring'
    ],
    negativeExamples: [ // negative examples
      'What can log query do'             // asking about capability, not querying
    ],
    requiredSlots: ['serviceId', 'environment'], // if missing, clarify
    allowedModes: ['execute', 'clarify'] // answer is not allowed to impersonate explanation type
  }
]

Positive examples tell the model “what counts as this type”; negative examples separate adjacent intents that are easy to confuse.

Intent is “what the user wants to achieve”; tools are just how to do it at the moment. Don't bind them one-to-one. For example, the user wants to “check service health”:

When there are few tools and fixed steps, entering the corresponding handler after recognizing the intent is fine. Once the flow gets longer, don't make the intent layer both guess the goal and arrange the tool order: first figure out what the user wants, then decide which tools to call and in what order. Also don't create a new intent label for each combination of “query logs + check monitoring + restart”; labels will explode.

When the system is still simple, you can start with a big umbrella intent like diagnose_service. When it becomes complicated, a more stable approach is to let the intent layer only state “which goals need to be completed”, and later plan “what to do first, what to do if it fails”.

Protocol, Prompt, and routing code

Once the intent table is written, four more steps are needed on the code side: first define what the return looks like, then use Prompt to have the model fill it in that way, then check it again in code, then decide by mode whether to answer, ask, act, or reject. The four paragraphs below are one step each.

1. Use Zod to define the output shape

The model gives “I guess the user wants this”, and a program cannot branch on arbitrary text. So first use Zod (or the same kind of JSON Schema) to lock the allowed fields: which keys exist, which values are allowed, and parameters without evidence must be null.

This Schema is used twice:

  1. When calling the model: if the provider supports structured output / Function Calling, use it and ask the model to fill in according to this table.
  2. When it comes back: code uses safeParse to validate again — the model can still miss fields or invent an intent id that doesn't exist.
import { z } from 'zod'

const IntentResultSchema = z.object({ // intent structured output contract
  // business goal: must fall within candidate enum, model forbidden to invent ids
  intent: z.enum([ // business goal enum, no invented ids
    'explain_log_capability',
    'query_service_logs',
    'query_service_status',
    'restart_service',
    'out_of_scope'
  ]),
  // processing mode: explain / execute / clarify / reject
  mode: z.enum(['answer', 'execute', 'clarify', 'reject']), // processing mode enum
  // observation only, cannot be used as a reliable probability gate
  confidence: z.number().min(0).max(1), // observation only, not a reliable gate
  // execution parameters; must be null if no evidence, guessing forbidden
  slots: z.object({ // execution parameters; must be null if no evidence
    serviceId: z.string().nullable(), // service id
    environment: z // environment enum
      .enum(['development', 'testing', 'production']) // environment enum
      .nullable(),                   // null when no evidence
    timeRange: z.string().nullable() // time window, e.g. 30m
  }),
  // required slots still missing; if any, mode should be clarify
  missingSlots: z.array( // still-missing required slots
    z.enum(['serviceId', 'environment', 'timeRange']) // slot enum that can be missing
  ),
  // clarification wording; can be null when not clarify
  clarificationQuestion: z.string().nullable() // clarification wording
})

type IntentResult = z.infer<typeof IntentResultSchema> // type definition

confidence can be glanced at, but don't treat it as a reliable probability. If the model writes 0.9 itself, that doesn't mean it really has 90% accuracy.

2. Prompt only gives boundaries and context

The Schema says “what the return looks like”; the Prompt says “how you judge”. Here, don't ask it to write business answers, and don't ask it to call tools — this step is only responsible for recognizing intent.

Usually the Prompt contains only: what you are, which candidate intents exist (with positive and negative examples), the last few turns of conversation and already confirmed state (to understand “it”, “the one from just now”), and the user's current raw sentence. Point the output format back to the Schema above; don't create another set of fields.

// Assemble intent analysis Prompt: task boundary + candidate definitions + context + current message
function buildIntentPrompt(input: { // Assemble intent analysis Prompt
  intents: IntentDefinition[] // candidate intents (with positive/negative examples)
  recentMessages: Array<{ role: 'user' | 'assistant'; content: string }> // recent turns, for anaphora
  state: Record<string, unknown> // confirmed structured state
  message: string               // current user message
}) {
  // Returns the prompt text for llm.generateStructured (not execution logic)
  return `
You are the intent analyzer for a conversational Agent. You are only responsible for understanding the user's goal and missing information.
You do not answer business questions and do not execute any tools.

Please follow:
1. Judge intent by combining the current message, recent conversation, and confirmed state.
2. Do not classify just because a keyword appears; understand negation, anaphora, and ellipsis.
3. When the user is only asking about functionality or concepts, choose an explanation intent; do not choose execution mode.
4. When required slots for execution are missing, mode must be clarify.
5. When uncertain or outside the candidate range, choose out_of_scope; do not invent new intent ids.
6. slots can only be filled with values that have evidence in the input or context; do not guess.

Candidate intents:
${JSON.stringify(input.intents, null, 2)}

Recent conversation:
${JSON.stringify(input.recentMessages, null, 2)}

Confirmed state:
${JSON.stringify(input.state, null, 2)}

Current user message:
${input.message}

Only return JSON that matches the agreed Schema.
`
}

If the model service itself supports JSON Schema or Function Calling, prefer using its native structured output; don't rely only on a line in the Prompt saying “just return JSON”.

3. analyzeIntent: parse and validate

analyzeIntent does two things: first ask the model, then inspect the goods itself. Don't reverse the order:

  1. Call the model with Schema and Prompt
  2. Use Zod to validate the fields again
  3. intent must be in the candidate list — don't let the model invent a new name
  4. mode must be allowed for this intent (e.g. explanation type cannot directly execute); reject is reserved for clearly out-of-scope, and doesn't need to be written into every intent's allowed list

If any step fails, return failure and let the upper layer retry or fall back. Don't continue running downstream with a half-correct result.

// 1) Call model  2) Zod  3) intent id in candidate list  4) mode in that intent's allowedModes (except reject)
async function analyzeIntent(input: AnalyzeInput) { // call model to get intent, then secondary validation
  const raw = await llm.generateStructured({ // prefer native structured output / function calling, don't rely only on “just return JSON”
    schema: IntentResultSchema, // Zod/JSON Schema contract
    prompt: buildIntentPrompt(input) // includes candidate intents and boundaries
  })

  const parsed = IntentResultSchema.safeParse(raw) // model can also emit illegal fields; code must validate again
  if (!parsed.success) { // Schema not passed: structured failure
    return { // Schema not passed: upstream should retry or fall back, don't route
      status: 'invalid_output' as const, // Schema not passed
      issues: parsed.error.issues // Zod issue list
    }
  }

  const definition = input.intents.find(item => item.id === parsed.data.intent) // forbid model-invented intent ids
  if (!definition) { // intent id not in candidate list
    return { status: 'unknown_intent' as const } // intent id not in candidate list
  }

  // Explanation type cannot execute: mode must fall within the allowedModes declared by the intent
  // reject is reserved for out_of_scope and similar overflows; not required to be in every intent's allowedModes
  if (
    parsed.data.mode !== 'reject' &&
    !definition.allowedModes.includes(parsed.data.mode)
  ) {
    return {
      status: 'invalid_mode' as const, // mode conflicts with intent definition
      intent: parsed.data.intent,
      mode: parsed.data.mode
    }
  }

  return { // validation passed: can be handed to gate + routeIntent
    status: 'success' as const, // validation passed
    result: parsed.data // structured intent that can be routed
  }
}

4. routeIntent: dispatch by mode

At this point, the user's sentence has already been judged. routeIntent no longer reads the original sentence; it only looks at the validated mode and decides what to do next:

In one sentence: what comes in is a validated intent result, what goes out is one of answer, ask, do, reject.

// Dispatches on validated mode — no longer guesses natural language here
async function routeIntent(result: IntentResult, context: Context) { // dispatch by valid mode
  switch (result.mode) { // dispatch by validated mode; no longer guesses the user's sentence
    case 'answer': // explanation type
      return answerKnowledgeQuestion(result, context) // explanation type: query knowledge/policy, don't call write tools
    case 'clarify': // missing slot clarification
      return { // missing slot: only ask what is missing, don't guess environment
        type: 'clarification', // frontend renders parameter-completion card
        content: result.clarificationQuestion // ask only the missing item
      }
    case 'execute': // execution type
      await authorizeIntent(result.intent, context.auth) // re-authorize before execution; identity follows context.auth
      return executeIntent(result, context) // enter tool/planning
    case 'reject': // out-of-scope rejection
      return { // out-of-scope / cannot execute: direct rejection wording, no tool call
        type: 'rejected', // routing result: rejected
        content: 'The current request cannot be executed' // rejection wording
      }
  }
}

When actually going live, there is usually a dispatchAfterGate in front: high-risk actions need human confirmation before execute; missing parameters should be forced to clarify. routeIntent only handles where to go after mode passes the gate.

Missing slots, anaphora, and the risk gate

User says: “Check the error logs of the payment service.”

The service and goal are already clear; environment is still unknown. The correct approach is to ask only the missing item, neither guessing production nor asking the user to fill the whole form again.

After the user answers: “production”.

Merge it with the previous round's “what is still missing” state, then check again. This pending-completion state should have an expiry: if the user replies “production” a long time later, don't assume it still answers the previous question.

// Pending completion state: merge only missing slots, invalidate after expiry, avoid attaching “production” to an old question
type PendingSlotFill = {
  intent: string                         // intent id waiting to be completed
  slots: Record<string, string | null>   // filled slots; unfilled is null
  missingSlots: string[]                 // required slot names still missing
  expiresAt: number                      // if exceeded, discard and ask user to restate the full request
}

// Merge user-supplied slot into pending state; invalidate after expiry, avoid attaching to old question
function mergeSlotAnswer(
  pending: PendingSlotFill, // pending state
  answer: { environment?: string }, // user's current slot answer
  now = Date.now() // injectable clock, for unit-testing expiry branch
) {
  if (now > pending.expiresAt) { // expired: don't attach “production” to a question from long ago
    return { status: 'expired' as const } // expired: ask user to restate
  }
  const slots = { ...pending.slots, ...answer } // only overwrite slots given in this round
  // required slots still missing; if any, mode stays clarify
  const missingSlots = pending.missingSlots.filter(key => !slots[key])
  return { // merge success: decide clarify/execute by whether slots are still missing
    status: 'ok' as const, // merge success
    result: {
      intent: pending.intent, // inherit pending intent
      mode: missingSlots.length ? 'clarify' : 'execute', // if still missing, keep clarifying
      slots, // merged slots
      missingSlots // slots still missing
    }
  }
}

Anaphora depends on structured state, not stuffing the whole history in

When the user says “it” or “the one from just now”, context is indeed needed, but don't stuff the entire chat log into the model.

What is usually enough: the last few turns of raw text, the object recorded by the most recent successful operation, the intent currently being filled, and state the user explicitly confirmed. Only update currentService when a tool actually succeeded or the user explicitly selected it; when a lookup fails, don't treat the service name guessed by the model as confirmed fact.

Risk takes priority over confidence

The model not being very sure doesn't necessarily mean you must follow up. If confidence < 0.8 always triggers confirmation, you'll create a lot of unnecessary interruptions. A more reasonable approach is to look at risk and rollback-ability together:

Reading public information can tolerate a bit more uncertainty; but deleting data or releasing a version, even with a clear intent, should be confirmed once more and permissions checked again.

confirm is not a value of mode; it's an extra gate before dispatch: high-risk actions first pop up a confirmation (HITL, human-in-the-loop), and only after the user agrees and permissions are rechecked is execute allowed. If parameters are missing, change mode to clarify, then enter routeIntent.

type Gate = 'clarify' | 'confirm' | 'continue'

// Gate order: missing slots > high risk > extremely low confidence; don't make confidence the only switch
// 0.35 is the author's suggested starting point; calibrate with your own evaluation set
function decideGate(result: IntentResult, risk: 'low' | 'high'): Gate {
  if (result.missingSlots.length > 0) return 'clarify' // missing required slots: clarify first, no guessing environment/service
  if (risk === 'high') return 'confirm' // high risk: confirm again even if intent is clear
  if (result.confidence < 0.35) return 'clarify' // follow up only at extremely low confidence; don't use a high threshold like 0.8 to create useless interactions
  return 'continue' // low risk and slots complete: proceed
}

// Gate -> route: confirm enters HITL, not faked as mode
async function dispatchAfterGate(
  result: IntentResult,
  risk: 'low' | 'high',
  context: Context
) {
  const gate = decideGate(result, risk)
  if (gate === 'confirm') {
    return {
      type: 'interrupt' as const, // aligned with RunEvent.interrupt / HITL
      interruptType: 'confirm_risky',
      reason: 'High-risk operation requires confirmation',
      payload: { intent: result.intent, slots: result.slots } // confirmation card shows the proposal, does not execute directly
    }
  }
  if (gate === 'clarify') {
    // force the clarification branch, avoiding execute while slots are missing
    return routeIntent({ ...result, mode: 'clarify' }, context)
  }
  return routeIntent(result, context) // continue: dispatch by original mode
}

Evaluation, evolution, and common pitfalls

Evaluation samples should at least cover: normal phrasing, rephrased phrasing, sentences with negation, the boundary between explanation and execution types, anaphora, “still missing parameters”, multiple goals in one message, clearly out-of-scope, and intents with similar names that are easy to confuse. (Feels pretty convoluted... but that's really what's needed)

// Evaluation case example: test whether anaphora “it” inherits the service and environment from the previous round
{
  id: 'intent-017',            // case id
  messages: [                  // multi-turn input
    { role: 'user', content: 'Check the payment-api in production' }, // first round: names service and environment
    { role: 'assistant', content: 'The service is currently running normally' }, // previous assistant reply
    { role: 'user', content: 'Does it have error logs recently' } // “it” = payment-api
  ],
  expected: {                  // expected structured result
    intent: 'query_service_logs', // should recognize as query logs
    mode: 'execute', // slots complete, can execute
    slots: { // “it” should inherit service and environment from two turns above
      serviceId: 'payment-api',   // should inherit from context
      environment: 'production'   // should inherit from context
    }
  }
}

How to split metrics

Calculate separately: whether intent is correct, whether mode is correct, whether parameters are filled correctly, whether clarification should have happened, whether any wrong execution happened, and whether clarification asks only for the item truly missing.

Looking only at overall accuracy can deceive: when 90% of samples are queries, the model can score high even if it guesses “query” for everything. You should look at how many of each intent were correct and which intents it confuses with each other.

How to fix online misclassification

When production misjudges, investigate in this order:

Find the first broken layer and fix only that layer. For example, when “what is log query used for” is treated as a live query: don't add includes('what is it used for') to the code. Add definition and positive examples to the explanation type, add a negative example to the live query, add regression cases for similar phrasing, and confirm the real query isn't collateral damage.

When there are fewer than ten intents, one model call can both classify and fill parameters. After intents grow, you can first recognize a broad category (like “logs” or “release”), then choose the specific intent within that category — both layers still have the model output according to a candidate list by format, not by matching keywords with includes.

Later, you can gradually add (versioning matters, e.g. SSE interruption and user continuing execution, which version do you use):

Group high-frequency unknown requests into one category to judge whether a new capability is truly missing. Seeing an unfamiliar phrasing and adding an intent immediately is usually wrong. A new intent should represent a new goal, not an old goal phrased differently.

Common pitfalls

You don't have to roll it all out at once. You can start with a few core intents that are clearly separable, write definitions, positive/negative examples, and required parameters, connect structured output and Zod, then evaluate with an annotated set close to real wording. There is no universal threshold for how many intents or samples you need; it depends on whether scenarios are covered and where errors mainly fall. Then add conversation state, asking when parameters are missing, high-risk confirmation, and misclassification feedback loops.

Summary

The above may feel tedious, but that's fine; just remember this summary. This article establishes three boundaries first:

  1. The model is responsible for understanding and outputting intent in the agreed format; code is only responsible for checking and dispatching downstream. (Back to basics; this sentence is the essence.)
  2. Don't use keywords or regex to guess what the user wants. Whether something can be done is locked down by rules; what the user wants is left to the model.
  3. Ask clearly when parameters are missing; let humans confirm high risk. The confidence written by the model itself is only a reference; don't use it as the only switch.

There is a lot of practice in this article; be sure to combine it with the “Summary” and read the article several times. Next article continues: “ReAct Loop: From a Single Function Call to a Multi-Step Agent”.

I hope this article helps you and gives you something to learn from.