意图识别与实体识别(Intent + NER)工程指南

150 阅读14分钟

目录


一、任务定义

1.1 意图识别(Intent Classification)

判断用户输入属于哪一类(多分类或多标签):

"帮我查下明天上海到北京的高铁" → BookTrain
"我的快递到哪了"               → CheckDelivery
"取消订单"                     → CancelOrder

1.2 命名实体识别(NER)

抽取文本中的关键实体:

"帮我查下明天上海到北京的高铁"{
  "date":          "明天",
  "departure":     "上海",
  "destination":   "北京",
  "transport_type":"高铁"
}

1.3 联合任务(Joint NLU)

意图 + 实体一次输出:

{
  "intent": "BookTrain",
  "confidence": 0.96,
  "entities": {
    "date": "明天",
    "departure": "上海",
    "destination": "北京",
    "transport_type": "高铁"
  }
}

1.4 应用场景

  • 客服机器人:路由到对应技能
  • 语音助手:解析指令
  • 搜索 / RAG:理解用户检索意图
  • Agent 工具调用:function name + arguments 即 intent + entities

二、技术路线对比

路线准确率延迟成本数据需求可解释适用
规则 / 模板极低极低冷启动、长尾兜底
BERT 分类 + CRF数千-万条高 QPS 稳定场景
LLM Zero/Few-shot中-高极少快速上线、长尾意图
LLM 微调极高中-高数千条需要兼顾准确率与灵活性
Embedding 检索 + 分类中-高数百-千条意图频繁新增

2.1 选择决策树

意图集合稳定且 QPS 高?
   ├ 是 ─→ BERT/CRF 微调
   └ 否 ─→ 数据少且常变?
              ├ 是 ─→ LLM Few-shot 或 Embedding 检索
              └ 否 ─→ LLM 微调(LoRA)

三、数据准备

3.1 数据格式

意图分类(CSV/JSONL):

{"text": "查询订单状态", "intent": "CheckOrder"}
{"text": "明天天气怎么样", "intent": "QueryWeather"}

NER(BIO/BIOES 标注):

查 O
询 O
张 B-PERSON
三 I-PERSON
的 O
订 B-ORDERI-ORDER

或 span 格式:

{
  "text": "查询张三的订单",
  "spans": [
    {"start": 2, "end": 4, "label": "PERSON"},
    {"start": 5, "end": 7, "label": "ORDER"}
  ]
}

3.2 标注工具

工具优势适用
Label Studio自部署、强大团队标注
doccano轻量、开源小团队
Prodigy主动学习、Spacy 集成商业项目
Argilla与 LLM 协作LLM 数据闭环

3.3 数据增强

# 1. 同义替换(基于词表/embedding)
def synonym_aug(text):
    return text.replace("查询", random.choice(["查一下", "看下", "帮我查"]))

# 2. 回译(Translate → Back-translate)
en = translate_zh_en(text)
aug = translate_en_zh(en)

# 3. LLM 改写
llm("以不同口语表达保留含义改写:" + text)

# 4. 实体替换(保持标签的同时换实体)
# "明天上海到北京" → "后天广州到深圳"

3.4 数据质量

  • 每个意图至少 50 条,长尾意图可补 LLM 合成
  • 类别不平衡:oversampling 少数类 + 加权交叉熵
  • 标注一致性:双标 + Cohen's Kappa > 0.8
  • 保留真实分布的测试集:不要从训练集切

四、Schema 设计

4.1 意图层级

扁平 vs 层级:

扁平:
  CheckOrder, BookOrder, CancelOrder, ReturnOrder, ...

层级:
  Order
    ├── Check
    ├── Book
    ├── Cancel
    └── Return

层级好处:

  • 路由时先大类再细分,提高鲁棒性
  • 长尾子意图可降级到父类

4.2 实体类型设计

实体类型示例备注
通用实体PERSON, LOCATION, DATE, MONEY复用预训练
业务实体ORDER_ID, PRODUCT_SKU, USER_LEVEL领域定制
槽位departure_city, return_date任务相关

4.3 槽位 vs 实体

  • 实体(Entity):通用、文本本身的属性
  • 槽位(Slot):意图所需的参数,可能由实体填充
实体: city = "上海"
槽位: departure_city = "上海"  ← 由 city + 上下文角色填入

4.4 Schema 版本管理

# schema.yaml
version: 1.2.0
intents:
  - name: BookTrain
    description: 预订火车票
    slots:
      - { name: departure, type: city, required: true }
      - { name: destination, type: city, required: true }
      - { name: date, type: date, required: true }
      - { name: passengers, type: integer, default: 1 }
      - { name: train_type, type: enum, values: [高铁, 动车, 普快] }

五、传统方法:BERT 分类 + CRF

5.1 模型架构

意图:[CLS] → Linear → softmax
NER :每个 token → Linear → CRF → BIO tags
联合:共享 BERT encoder + 两个 head

5.2 训练(HuggingFace)

from transformers import (
    AutoTokenizer, AutoModelForSequenceClassification,
    TrainingArguments, Trainer
)
from datasets import Dataset
import torch

MODEL = "hfl/chinese-roberta-wwm-ext"
tokenizer = AutoTokenizer.from_pretrained(MODEL)

intents = ["BookTrain", "CheckOrder", "QueryWeather", "Other"]
i2l = {i: l for i, l in enumerate(intents)}
l2i = {l: i for i, l in i2l.items()}

def preprocess(ex):
    enc = tokenizer(ex["text"], truncation=True, max_length=64)
    enc["label"] = l2i[ex["intent"]]
    return enc

train_ds = Dataset.from_json("train.jsonl").map(preprocess)
val_ds   = Dataset.from_json("val.jsonl").map(preprocess)

model = AutoModelForSequenceClassification.from_pretrained(
    MODEL, num_labels=len(intents),
    id2label=i2l, label2id=l2i,
)

args = TrainingArguments(
    output_dir="./intent",
    learning_rate=3e-5,
    per_device_train_batch_size=64,
    num_train_epochs=5,
    eval_strategy="epoch",
    metric_for_best_model="f1",
    load_best_model_at_end=True,
)

def metrics(p):
    preds = p.predictions.argmax(-1)
    from sklearn.metrics import f1_score
    return {"f1": f1_score(p.label_ids, preds, average="macro")}

Trainer(
    model=model, args=args,
    train_dataset=train_ds, eval_dataset=val_ds,
    compute_metrics=metrics,
).train()

5.3 NER 训练(Token Classification)

from transformers import AutoModelForTokenClassification, DataCollatorForTokenClassification

ner_labels = ["O", "B-CITY", "I-CITY", "B-DATE", "I-DATE"]
l2i = {l: i for i, l in enumerate(ner_labels)}

def tokenize_align(ex):
    enc = tokenizer(ex["tokens"], is_split_into_words=True, truncation=True)
    word_ids = enc.word_ids()
    labels = []
    prev = None
    for wid in word_ids:
        if wid is None:
            labels.append(-100)
        elif wid != prev:
            labels.append(l2i[ex["ner_tags"][wid]])
        else:
            labels.append(l2i[ex["ner_tags"][wid]])
        prev = wid
    enc["labels"] = labels
    return enc

model = AutoModelForTokenClassification.from_pretrained(MODEL, num_labels=len(ner_labels))

5.4 推理(带阈值与兜底)

import torch
from transformers import pipeline

clf = pipeline("text-classification", model="./intent", top_k=3)
ner = pipeline("ner", model="./ner", aggregation_strategy="simple")

def predict(text):
    intents = clf(text)[0]
    top = intents[0]
    # 阈值兜底
    if top["score"] < 0.6:
        return {"intent": "Other", "score": top["score"], "entities": []}
    return {
        "intent": top["label"],
        "score": top["score"],
        "entities": ner(text),
    }

5.5 加 CRF 层(可选,效果略升)

from torchcrf import CRF

class BertCRF(torch.nn.Module):
    def __init__(self, bert, n_labels):
        super().__init__()
        self.bert = bert
        self.classifier = torch.nn.Linear(bert.config.hidden_size, n_labels)
        self.crf = CRF(n_labels, batch_first=True)

    def forward(self, input_ids, attention_mask, labels=None):
        h = self.bert(input_ids, attention_mask=attention_mask).last_hidden_state
        logits = self.classifier(h)
        if labels is not None:
            mask = attention_mask.bool()
            loss = -self.crf(logits, labels, mask=mask)
            return loss
        return self.crf.decode(logits, mask=attention_mask.bool())

六、现代方法:LLM 抽取

6.1 Zero-shot Prompt

from openai import OpenAI
client = OpenAI()

SYSTEM = """你是 NLU 系统,从用户输入中识别意图和实体,严格输出 JSON。

意图集合:BookTrain, CheckOrder, QueryWeather, Other

实体类型:city, date, order_id, train_type

输出格式:
{"intent": "...", "entities": {...}}
"""

def nlu(text):
    resp = client.chat.completions.create(
        model="claude-haiku-4-5",
        messages=[
            {"role": "system", "content": SYSTEM},
            {"role": "user", "content": text},
        ],
        response_format={"type": "json_object"},
    )
    return json.loads(resp.choices[0].message.content)

6.2 Function Calling(更可靠)

tools = [{
    "type": "function",
    "function": {
        "name": "BookTrain",
        "description": "预订火车票",
        "parameters": {
            "type": "object",
            "properties": {
                "departure":    {"type": "string", "description": "出发城市"},
                "destination":  {"type": "string", "description": "目的城市"},
                "date":         {"type": "string", "description": "日期 YYYY-MM-DD 或自然语言"},
                "train_type":   {"type": "string", "enum": ["高铁", "动车", "普快"]},
            },
            "required": ["departure", "destination", "date"],
        },
    },
}, {
    "type": "function",
    "function": {
        "name": "CheckOrder",
        "description": "查询订单",
        "parameters": {
            "type": "object",
            "properties": {"order_id": {"type": "string"}},
            "required": ["order_id"],
        },
    },
}]

def nlu(text):
    resp = client.chat.completions.create(
        model="claude-haiku-4-5",
        messages=[{"role": "user", "content": text}],
        tools=tools, tool_choice="auto",
    )
    msg = resp.choices[0].message
    if msg.tool_calls:
        call = msg.tool_calls[0]
        return {"intent": call.function.name,
                "entities": json.loads(call.function.arguments)}
    return {"intent": "Other", "entities": {}}

Function calling 比纯 prompt 更稳定,模型有专门优化。

6.3 LLM 微调(LoRA)

数据集(OpenAI 微调格式):

{"messages": [
  {"role":"system","content":"NLU 系统..."},
  {"role":"user","content":"明天上海到北京的高铁"},
  {"role":"assistant","content":"{\"intent\":\"BookTrain\",\"entities\":{...}}"}
]}

LoRA 训练(PEFT):

from peft import LoraConfig, get_peft_model
from transformers import AutoModelForCausalLM

base = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-7B-Instruct", torch_dtype="auto")
lora_cfg = LoraConfig(r=16, lora_alpha=32, target_modules=["q_proj", "v_proj"], lora_dropout=0.05)
model = get_peft_model(base, lora_cfg)
# ... 训练 SFT

6.4 优势对比

传统 BERT/CRFLLM 抽取
训练数据需求数千+几条到 0
新意图扩展需重训改 prompt/tools
模糊/口语化
推理延迟< 50ms200-2000ms
推理成本高(10-100×)

七、混合架构

实际生产常用双层架构

                  ┌─────────────────┐
   用户输入 ────> │  Embedding 检索 │ ─→ 高置信 ─→ 直接返回
                  └────────┬────────┘
                           │ 低置信
                           ▼
                  ┌─────────────────┐
                  │  BERT 分类      │ ─→ 高置信 ─→ 返回
                  └────────┬────────┘
                           │ 低置信
                           ▼
                  ┌─────────────────┐
                  │  LLM 兜底       │ ─→ 返回
                  └─────────────────┘

7.1 Embedding 召回(快速 + 可解释)

import faiss, numpy as np
from sentence_transformers import SentenceTransformer

encoder = SentenceTransformer("BAAI/bge-large-zh-v1.5")
examples = load_examples()  # [(text, intent)]
texts, intents = zip(*examples)

embeddings = encoder.encode(list(texts), normalize_embeddings=True)
index = faiss.IndexFlatIP(embeddings.shape[1])
index.add(embeddings.astype("float32"))

def retrieve_intent(text, k=5, threshold=0.85):
    q = encoder.encode([text], normalize_embeddings=True).astype("float32")
    scores, idx = index.search(q, k)
    if scores[0][0] < threshold:
        return None  # 低置信,走下一层
    # k=5 投票
    votes = {}
    for s, i in zip(scores[0], idx[0]):
        votes[intents[i]] = votes.get(intents[i], 0) + s
    return max(votes, key=votes.get)

7.2 优势

  • 新意图增量加入:仅写入向量库,无需重训
  • 高置信样本秒级响应
  • LLM 仅处理 5-15% 的疑难 case,成本低

八、Few-shot / In-context Learning

8.1 静态示例

SYSTEM = """从用户输入识别意图与实体。

示例:
输入:明天去上海的高铁票
输出:{"intent":"BookTrain","entities":{"date":"明天","destination":"上海","train_type":"高铁"}}

输入:我的快递到哪了
输出:{"intent":"CheckDelivery","entities":{}}

输入:取消订单 12345
输出:{"intent":"CancelOrder","entities":{"order_id":"12345"}}
"""

8.2 动态示例(KNN + LLM)

def build_prompt(text):
    # 用 embedding 检索 top-5 最相似的标注样本
    similar = retrieve_top_k(text, k=5)
    examples = "\n\n".join(
        f"输入:{e['text']}\n输出:{json.dumps(e['label'], ensure_ascii=False)}"
        for e in similar
    )
    return f"{SYSTEM}\n\n{examples}\n\n输入:{text}\n输出:"

KNN-augmented 通常比静态 few-shot 强 3-8 个百分点。

8.3 Chain-of-Thought(复杂场景)

请分两步:
1. 先分析用户的核心需求(reasoning)
2. 再给出最终 JSON(answer)

输入:帮我看看明天有没有从上海去北京的高铁,最好早上的
1. 用户在询问火车票,并指定了出发地、目的地、日期、时间偏好。
2. {"intent":"BookTrain","entities":{...}}

九、约束解码与结构化输出

9.1 JSON Schema 约束

vLLM / SGLang / OpenAI 都支持:

schema = {
    "type": "object",
    "properties": {
        "intent": {"type": "string", "enum": ["BookTrain", "CheckOrder", "Other"]},
        "entities": {"type": "object"},
    },
    "required": ["intent", "entities"],
}

# OpenAI
client.chat.completions.create(
    model="...",
    messages=[...],
    response_format={"type": "json_schema",
                     "json_schema": {"name": "nlu", "schema": schema, "strict": True}},
)

# SGLang
import sglang as sgl
@sgl.function
def nlu(s, text):
    s += SYSTEM + "\n用户:" + text + "\n输出:"
    s += sgl.gen("out", json_schema=schema)

9.2 Outlines(开源约束生成)

import outlines
from pydantic import BaseModel

class NLUResult(BaseModel):
    intent: Literal["BookTrain", "CheckOrder", "Other"]
    entities: dict[str, str]

model = outlines.models.transformers("Qwen/Qwen2.5-7B-Instruct")
gen = outlines.generate.json(model, NLUResult)
result: NLUResult = gen("明天上海到北京的高铁")

收益:100% 合法 JSON、字段值在枚举范围内。

9.3 注意

  • 约束解码会让生成更慢 5-15%
  • 不能纠正语义错误:只能保证形式合法
  • 强 schema + 充分示例是最稳的组合

十、模型评估

10.1 意图指标

from sklearn.metrics import classification_report, confusion_matrix

print(classification_report(y_true, y_pred, digits=4))
# precision / recall / f1-score / support

关键:macro-F1(每类平均),不是 accuracy(不平衡数据下误导)。

10.2 NER 指标(seqeval)

from seqeval.metrics import classification_report

print(classification_report(y_true, y_pred))
# 严格 span 匹配的 P/R/F1

注意:实体级别 vs token 级别得分差异很大,报告必须用 span-level

10.3 端到端指标

NLU 系统真正关心:

  • Frame Accuracy:意图 + 全部实体都正确
  • Slot F1:每个槽位 P/R/F1
  • 意图正确率
def frame_accuracy(preds, golds):
    n = sum(1 for p, g in zip(preds, golds)
            if p["intent"] == g["intent"] and p["entities"] == g["entities"])
    return n / len(golds)

10.4 业务指标

  • 降级率:转人工 / 兜底 Other 的比例
  • 拒识率:低置信度直接拒绝的比例
  • 人工标注一致率:模型预测 vs 复核员的一致率

十一、生产部署

11.1 服务架构

        ┌──────────────┐
请求 ─→ │ Tokenizer    │
        ├──────────────┤
        │ Embedding检索│ ←─ FAISS / Qdrant
        ├──────────────┤
        │ BERT 分类    │ ←─ ONNX / Triton
        ├──────────────┤
        │ NER          │
        ├──────────────┤
        │ Rule 后处理  │ ←─ 正则修补、归一化
        ├──────────────┤
        │ LLM 兜底     │ ←─ 仅低置信触发
        └──────────────┘
            ↓
        统一 JSON 输出

11.2 ONNX 加速

from optimum.onnxruntime import ORTModelForSequenceClassification

model = ORTModelForSequenceClassification.from_pretrained(
    "./intent",
    export=True, provider="CUDAExecutionProvider",
)

通常 2-5× 加速。

11.3 Triton 部署

model_repository/
├── intent/
│   ├── 1/model.onnx
│   └── config.pbtxt
├── ner/
│   ├── 1/model.onnx
│   └── config.pbtxt
└── nlu_pipeline/
    ├── 1/model.py        # Python backend 串联
    └── config.pbtxt
# nlu_pipeline/1/model.py
import triton_python_backend_utils as pb_utils

class TritonPythonModel:
    def execute(self, requests):
        responses = []
        for req in requests:
            text = pb_utils.get_input_tensor_by_name(req, "TEXT")
            intent = self._call_intent(text)
            entities = self._call_ner(text)
            responses.append(pb_utils.InferenceResponse(
                output_tensors=[
                    pb_utils.Tensor("INTENT", intent),
                    pb_utils.Tensor("ENTITIES", entities),
                ]
            ))
        return responses

11.4 缓存

热门 query 直接命中缓存(Redis):

import hashlib, redis, json
r = redis.Redis()

def cache_key(text):
    return "nlu:" + hashlib.md5(text.encode()).hexdigest()

def predict_cached(text):
    k = cache_key(text)
    v = r.get(k)
    if v:
        return json.loads(v)
    result = predict(text)
    r.setex(k, 3600, json.dumps(result))
    return result

短文本场景缓存命中率可达 30-50%。

11.5 灰度

按用户 ID 哈希切流量,新模型先放 1% → 10% → 100%,对比业务指标后再切。


十二、在线优化

12.1 数据闭环

线上请求 ─→ 模型预测
              │
              ├─ 高置信 ─→ 直接使用
              ├─ 中置信 ─→ 抽样人工标注 ─→ 加入训练集
              └─ 低置信 ─→ 全量人工标注 + 兜底
                              │
                              ▼
                          下次迭代

12.2 主动学习

import numpy as np

def uncertainty_sampling(probs, n=100):
    # 选熵最高的 n 个
    entropy = -np.sum(probs * np.log(probs + 1e-12), axis=1)
    return entropy.argsort()[-n:]

每周抽不确定样本人工标注,迭代成本最低。

12.3 模型监控

指标告警条件
平均置信度较基线下降 5%+
兜底率上升 10%+
各意图占比分布漂移
p95 延迟上升 50%+
标注样本召回的 F1下降

数据漂移检测(PSI / KL 散度):

from scipy.stats import entropy
def psi(expected, actual, bins=10):
    e = np.histogram(expected, bins)[0] / len(expected)
    a = np.histogram(actual, bins)[0] / len(actual)
    return np.sum((a - e) * np.log((a + 1e-9) / (e + 1e-9)))
# > 0.2 显著漂移

十三、错误分析与调优

13.1 错误归因

def analyze_errors(preds, golds):
    errors = []
    for p, g, t in zip(preds, golds, texts):
        if p["intent"] != g["intent"]:
            errors.append({"text": t, "pred": p, "gold": g, "type": "intent"})
        for k, v in g["entities"].items():
            if p["entities"].get(k) != v:
                errors.append({"text": t, "pred": p, "gold": g, "type": f"entity:{k}"})
    return errors

type 分桶分析占比,优先修最大头。

13.2 常见错误模式

模式解决
标签噪声复审训练集,剔除/修正
意图边界模糊合并相近意图 / 加判别样本
长尾意图欠召回数据增强 / oversampling
实体边界错位CRF / 后处理正则
实体类型混淆加更多对抗样本
OOV 实体gazetteer 字典补充

13.3 字典 + 模型混合

import re

CITY_DICT = load_cities()  # set of 4000+ 城市名
CITY_RE = re.compile("|".join(map(re.escape, sorted(CITY_DICT, key=len, reverse=True))))

def fix_entities(text, entities):
    # 模型漏识别的城市,用字典补
    found_cities = CITY_RE.findall(text)
    for city in found_cities:
        if city not in entities.values():
            entities.setdefault("city", city)
    return entities

十四、常见问题

14.1 训练 F1 高,线上效果差

  • 训练/测试分布不一致:用真实流量重新切分
  • 标注偏差:交叉复核
  • 时间漂移:用最近 2 周数据重训

14.2 LLM 输出 JSON 不合法

  • response_format / function calling
  • strict=True(OpenAI Structured Outputs)
  • 用 Outlines / SGLang 约束解码

14.3 实体重叠(嵌套)

如"上海市黄浦区"中 city + district 重叠:

  • 用 span-based 模型(如 W2NER、SpERT)
  • 或拆成多任务 head

14.4 类别极度不平衡

  • 损失加权:CrossEntropyLoss(weight=class_weights)
  • Focal Loss
  • Re-sampling
  • LLM 合成少数类样本

14.5 多语言

  • xlm-roberta-base / bge-m3 这类多语模型
  • LLM 抽取天然多语
  • 不同语种维护各自 few-shot 示例

14.6 私有化部署 LLM 太贵

  • 蒸馏成 BERT:用 LLM 标 10w 条 → 训 BERT,准确率 90% LLM 但成本 1%
  • 用小模型(Qwen2.5-1.5B/3B + LoRA)
  • 量化(AWQ)

十五、完整示例:客服 NLU 系统

业务:电商客服机器人。

15.1 Schema

version: 1.0
intents:
  - { name: CheckOrder,    desc: 查询订单 }
  - { name: ApplyRefund,   desc: 申请退款 }
  - { name: ShippingQuery, desc: 物流查询 }
  - { name: ProductInfo,   desc: 商品咨询 }
  - { name: Other,         desc: 其他 }

entities:
  - order_id:    {type: regex, pattern: "\\d{10,16}"}
  - product_sku: {type: regex, pattern: "SKU[A-Z0-9]+"}
  - amount:      {type: money}
  - reason:      {type: enum, values: [质量问题, 不喜欢, 错发漏发, 其他]}

15.2 三层架构

import re, json, redis, hashlib
from sentence_transformers import SentenceTransformer
from transformers import pipeline
from openai import OpenAI

class NLUEngine:
    def __init__(self):
        self.encoder = SentenceTransformer("BAAI/bge-large-zh-v1.5")
        self.bert_intent = pipeline("text-classification", model="./intent", top_k=1)
        self.bert_ner    = pipeline("ner", model="./ner", aggregation_strategy="simple")
        self.llm = OpenAI()
        self.r = redis.Redis()
        self._build_index()

    def _build_index(self):
        examples = json.load(open("examples.json"))
        self.example_texts = [e["text"] for e in examples]
        self.example_labels = [e["label"] for e in examples]
        embs = self.encoder.encode(self.example_texts, normalize_embeddings=True)
        import faiss
        self.idx = faiss.IndexFlatIP(embs.shape[1])
        self.idx.add(embs.astype("float32"))

    def _retrieve(self, text, k=5):
        q = self.encoder.encode([text], normalize_embeddings=True).astype("float32")
        scores, idx = self.idx.search(q, k)
        return scores[0], idx[0]

    def _bert_predict(self, text):
        intent = self.bert_intent(text)[0][0]
        ents = self.bert_ner(text)
        return {"intent": intent["label"], "score": intent["score"], "entities": ents}

    def _llm_predict(self, text, similar_examples):
        examples = "\n".join(
            f"输入: {self.example_texts[i]}\n输出: {json.dumps(self.example_labels[i], ensure_ascii=False)}"
            for i in similar_examples
        )
        prompt = f"""你是 NLU 系统。根据示例识别意图与实体,输出 JSON。

示例:
{examples}

输入: {text}
输出:"""
        resp = self.llm.chat.completions.create(
            model="claude-haiku-4-5",
            messages=[{"role": "user", "content": prompt}],
            response_format={"type": "json_object"},
        )
        return json.loads(resp.choices[0].message.content)

    def _post_process(self, text, result):
        # 用正则补全
        order_match = re.search(r"\d{10,16}", text)
        if order_match:
            result.setdefault("entities", {})["order_id"] = order_match.group()
        return result

    def predict(self, text):
        k = "nlu:" + hashlib.md5(text.encode()).hexdigest()
        if cached := self.r.get(k):
            return json.loads(cached)

        # 1) 检索高置信
        scores, idx = self._retrieve(text)
        if scores[0] > 0.92:
            result = {"intent": self.example_labels[idx[0]]["intent"],
                      "entities": self.example_labels[idx[0]].get("entities", {}),
                      "source": "retrieval"}
        else:
            # 2) BERT
            bert_out = self._bert_predict(text)
            if bert_out["score"] > 0.85:
                result = {**bert_out, "source": "bert"}
            else:
                # 3) LLM 兜底
                result = {**self._llm_predict(text, idx), "source": "llm"}

        result = self._post_process(text, result)
        self.r.setex(k, 600, json.dumps(result, ensure_ascii=False))
        return result

15.3 FastAPI 服务

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()
engine = NLUEngine()

class Req(BaseModel):
    text: str

class Resp(BaseModel):
    intent: str
    entities: dict
    source: str

@app.post("/nlu", response_model=Resp)
def nlu(req: Req):
    return engine.predict(req.text)

@app.get("/health")
def health():
    return {"status": "ok"}

15.4 监控指标

from prometheus_client import Counter, Histogram, start_http_server

REQ = Counter("nlu_requests_total", "请求数", ["intent", "source"])
LAT = Histogram("nlu_latency_seconds", "延迟", ["source"])

# 在 predict 内:
REQ.labels(intent=result["intent"], source=result["source"]).inc()
LAT.labels(source=result["source"]).observe(elapsed)

附录:速查

A.1 模型选型

任务中文英文/多语
句向量bge-large-zh-v1.5bge-m3
分类hfl/chinese-robertaxlm-roberta-base
NERhfl/chinese-roberta + CRFxlm-roberta + CRF
LLM 抽取(小)Qwen2.5-7B-InstructLlama-3.1-8B
LLM 抽取(大)Qwen2.5-72BClaude / GPT-4

A.2 数据量经验

路线每意图样本
LLM Few-shot3-10
Embedding 检索30-100
BERT 分类200-1000
LLM SFT50-500

A.3 常用库

用途
Tokenize/分类transformers
句向量sentence-transformers
向量索引faiss / qdrant / milvus
CRFtorchcrf / pytorch-crf
评估seqeval / scikit-learn
约束解码outlines / sglang
标注Label Studio / doccano / Argilla

A.4 性能基线

指标良好
意图 macro-F1> 0.92
NER span-F1> 0.88
Frame Accuracy> 0.80
p95 延迟< 100ms (BERT) / 1s (LLM)
兜底率< 8%