ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python+Gemini JSON Schema构建可校验的智能工单路由系统

Python+Gemini JSON Schema构建可校验的智能工单路由系统

先讲个背景。工单路由这活儿,我一直觉得是典型的“看起来简单、做起来复杂”的系统——每天几百张工单,要把它们准确分给能解决的人,效率和准确率都不能丢。我最近用 Python + Gemini JSON Schema 做了一套可校验的工单路由方案,已经稳定跑了一段时间,整体准确率在九成以上,人工兜底的比例也控制住了。这篇文章把整个思路、踩坑过程和工程细节都记录下来,给正在做工单系统、服务台、运维平台的读者一个可参考的落地样本。

如果你只是想让工单系统“聪明一点”,这篇里有几块内容值得重点看:为什么要放弃PPT式的关键词规则、如何设计一份真正能约束大模型输出的JSON Schema、在Python里怎么把它和Gemini API接起来,以及上线后最容易翻车的几个细节。我会尽量把“为什么这样设计”讲清楚,不只是丢代码。

1. 先聊清楚:为什么关键词路由和人工分单都撑不住

1.1 45条正则规则,覆盖不了60%的工单

我接手这个工单系统的时候,前一个团队留下的是基于正则表达式的关键词路由。规则写了一大摞,拿“密码”作为关键词去撞,拿“打印机”去撞,撞到谁就分给谁。真实跑起来,效果比想象中还要糟糕。

举几个真实的例子:用户报“我的密码过期了,无法登录邮箱”,规则里有一条“密码→账户安全组”,于是工单直接被分给了安全策略组。可实际上用户要的是重置密码,应该走账户管理团队。再有“办公区三楼的打印机经常卡纸”,这一句话同时命中“办公区/三楼”和“打印机”两个方向,正则谁先匹配谁说了算,没有任何语义判断。

后来规则数从20条涨到45条,覆盖率和准确率都没有明显上升。本质原因是:规则引擎在做“字符串查找”,而工单描述是“阅读理解题”。用户不会按你预设的关键词来描述问题,同一个故障在不同人嘴里有十种说法,规则永远追不上业务变化的节奏。

1.2 让LLM做路由,先解决“输出不可控”的问题

后来我试过直接让大模型分类工单。模型确实能读懂“打印机无法连接,无线网也不太稳定”这种复合描述,也能判断主要问题是什么,比起关键词匹配强了好几个量级。但裸调大模型的产物根本没法直接进生产链路——它会返回带Markdown格式的回答,会用各种措辞绕圈子,甚至自己发明一个系统里不存在的团队名。

做过这类尝试的读者应该都懂,LLM输出“我觉得应该分给网络组吧”这种句子,你想在代码里自动化处理,就得写一段非常脆弱的解析逻辑来清洗文本。清洗规则本身又变成了新的技术债,这跟关键词规则一样让人头疼。

1.3 为什么选Gemini JSON Schema作为结构化出口

真正让我决定用这个方案,是因为Gemini API原生支持在请求里挂JSON Schema,要求模型必须按我定义的JSON结构返回。有了这个约束,模型输出就变成了一个稳定的、可解析、可校验的JSON对象,而不是自由文本。

我最早也考虑过用通用提示词让模型“输出JSON”,结果就是反复调不稳,偶尔还会在JSON外面套个代码块。而直接把schema传给Gemini之后,配合温度设为0,输出格式基本是稳定的,后面再用jsonschema库独立校验一遍,双保险。这是整篇文章的核心地基。

2. 路由Schema的设计:从“模型自由发挥”到“协议约束”

2.1 先定业务字段,再定枚举

设计JSON Schema之前,我画了一张白板,认真想了一个问题:工单路由这个场景,我们最终需要的不是一个分类标签,而是一个“可执行的决策体”。模型要输出的东西必须能让工单系统直接执行。最终我定了六个字段:

  • target_team:路由目标团队,用枚举约束,这是核心决策字段;
  • category:问题子类型,是一句简短描述,用于后续统计和分析;
  • priority:优先级,分为LOW、MEDIUM、HIGH、URGENT四档;
  • confidence:模型对路由决策的置信度,0到1之间的浮点数;
  • requires_human:布尔值,标记此单是否需要人工复核;
  • reasoning:一两句话的决策理由,方便操作员快速理解模型为什么这样分。

你可能注意到了requires_human这个字段,它是我认为整个schema里最重要也最容易被忽略的设计。早期我做的时候没有这个字段,模型对模糊工单也会硬给一个团队,错分率很高。后来加了这个字段,规则变成:信息不足、多主题冲突或者置信度不高时,模型可以把requires_human置为true,同时target_team仍旧给出一个它认为“最可能”的答案。这样系统可以做到“先给方向、人工复核兜底”,比单纯触发一个低置信度阈值更自然,也更好监控。

2.2 一份能直接用的路由Schema

我现在的路由Schema长下面这样,字段都去了业务里的实际团队代号,你可以直接改代码里的枚举值套用:

ROUTING_SCHEMA = { "type": "OBJECT", "properties": { "target_team": { "type": "STRING", "enum": [ "ACCOUNT_TEAM", "NETWORK_TEAM", "HARDWARE_TEAM", "OFFICE_TEAM", "GENERAL_SERVICE_DESK" ], "description": "路由目标团队,只能从枚举值中选取;信息不足时选GENERAL_SERVICE_DESK。" }, "category": { "type": "STRING", "description": "简要问题分类,如‘账号锁定’‘无线网络不稳定’‘打印设备故障’。" }, "priority": { "type": "STRING", "enum": ["LOW", "MEDIUM", "HIGH", "URGENT"], "description": "工单优先级。" }, "confidence": { "type": "NUMBER", "description": "模型对路由结果的置信度,必须为0.0到1.0之间的浮点数。" }, "requires_human": { "type": "BOOLEAN", "description": "信息不足或无法确定案件方向时,置为true并转人工复核。" }, "reasoning": { "type": "STRING", "description": "用一到两句话说明路由理由。" } }, "required": [ "target_team", "category", "priority", "confidence", "requires_human", "reasoning" ] }

这里有一个细节值得单独说:Gemini API的response_schema虽然长得像标准JSON Schema,但实际用法有一些差异。比如很多教程里会把枚举类型写成"type": "ENUM"或者直接漏掉"type": "STRING",这两种写法我都试过,且踩过坑。某个版本的SDK会直接报出schema校验错误,或者模型返回的字段类型不稳定。后来我统一改成上面这种写法——外层是OBJECT,枚举字段的类型明确写STRING,枚举值放在"enum"数组里,稳定了很多。

再一个是字段嵌套的深度问题。第一版我为了“工程完整”,把reasoning也拆成几个子字段,还搞了一个sub_issues数组想装多个问题线索。结果模型在这个schema下还真会偶尔糊弄,嵌套对象的输出时好时坏。后来我把所有字段压平成一层,数组也去掉,准确率立刻上来了。所以我的建议是:路由场景下schema尽量保持扁平,输出结构越简单,模型执行越稳定。

2.3 枚举不是拿来就用的:给每个团队写路由手册

你可能觉得,把团队名单放进枚举就是万事大吉。实际上模型根本不知道每个团队具体负责什么,它只能靠团队名称去猜。比如NETWORK_TEAM这个名字,用户说“网络不稳定”它能猜对,但用户报“Wi-Fi连不上、外网丢包、网络认证失败”,模型就需要依赖路由手册里的解释了。

解决方法是:把每个团队的职责边界写到schema的description字段里,或者直接在prompt里加载一段路由手册。我的做法是两者结合——枚举字段的description写一句核心判断,prompt里再放一份完整路由手册。

路由手册: ACCOUNT_TEAM:登录失败、密码过期、多因素认证、账号权限异常、无法登录内部系统。 NETWORK_TEAM:办公区Wi-Fi连接失败、有线网络不通、网络认证认证异常、网速异常慢。 HARDWARE_TEAM:电脑无法开机、蓝屏、打印设备故障、外设损坏、设备采购与更换。 OFFICE_TEAM:办公区域物理环境问题,如门禁、工位、空调、会议室设备。 GENERAL_SERVICE_DESK:无法判断方向、信息不足、跨多个团队问题或以上类别均不匹配。

有了这份路由手册,模型才能从“猜名字”变成“读语义”。我第一次在这个方向做调优,没有加任何新模型,只是把这份手册写进prompt,错误路由的比例立刻降了差不多5个百分点。

2.4 多领域工单如何避免枚举爆炸

如果你所在的公司业务非常杂,工单覆盖IT、HR、行政、财务等多个领域,我不建议把所有团队都堆在一个枚举里。枚举超过20个之后,模型判断难度会明显上升。我当时的做法是做两级路由:

第一级用一个大类别枚举,比如IT_ISSUE、HR_ISSUE、FINANCE_ISSUE、ADMIN_ISSUE;拿到一级结果之后,再根据类别调用第二套schema做精细分单。这种两段式处理还有一个额外好处:每一段的schema都很小很清晰,模型输出质量高,出错了也容易定位。

3. Python集成实现:把Schema塞进Gemini请求

3.1 环境准备

我用的是Python 3.11,需要安装两个依赖:一个是Gemini的官方SDK,另一个是用于独立校验输出结果的标准jsonschema库。

pip install google-genai jsonschema

API密钥不要写死在代码里,从环境变量读取,这样在团队协作和部署时更安全。

import os from google import genai from google.genai import types from jsonschema import validate, ValidationError client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))

我是把Gemini API Key放在服务端环境变量里的,工单路由是一个后端服务,不会有前端暴露的问题。如果你是在本地写demo,临时用环境变量也能跑通。

3.2 最小可用的路由函数

核心函数不复杂,我直接把完整代码放在下面,这是一个可以复制后改一改就跑起来的版本:

ROUTING_SCHEMA = { ... } # 使用上一节定义的Schema def build_prompt(issue_text: str) -> str: return f"""你是一个工单路由引擎。请阅读工单描述,判断应该分派给哪个团队。 路由手册: ACCOUNT_TEAM:登录失败、密码过期、多因素认证、账号权限异常、无法登录内部系统。 NETWORK_TEAM:办公区Wi-Fi连接失败、有线网络不通、网络认证异常、网速异常慢。 HARDWARE_TEAM:电脑无法开机、蓝屏、打印设备故障、外设损坏、设备采购与更换。 OFFICE_TEAM:办公区域物理环境问题,如门禁、工位、空调、会议室设备。 GENERAL_SERVICE_DESK:无法判断方向、信息不足、跨多个团队问题或以上类别均不匹配。 工单描述: {issue_text} 请从枚举中选出最合适的团队,并按JSON Schema返回。""" def route_ticket(issue_text: str): response = client.models.generate_content( model="gemini-2.0-flash", contents=build_prompt(issue_text), config=types.GenerateContentConfig( temperature=0, top_p=1, response_mime_type="application/json", response_schema=ROUTING_SCHEMA, ), ) # 优先使用SDK自动解析的结果 if response.parsed is not None: payload = response.parsed else: # 兜底:手动解析文本,去掉可能的代码块围栏 raw = response.text.strip() if raw.startswith("```"): raw = raw.strip("`") if raw.startswith("json"): raw = raw[4:] payload = json.loads(raw) # 独立二次校验 validate(payload, ROUTING_SCHEMA) return payload

这里有几个关键点我想说明。

temperature=0是路由场景的必须设置。工单分派不是创意写作,我们要的是确定性,温度越低,模型每次针对同一工单给出相同结论的概率越高。response_mime_type="application/json"同样重要,它告诉Gemini直接生成JSON内容,不给Markdown包装。

response.parsed是新版SDK提供的能力。当你在请求中传入response_schema,SDK可以自动把模型输出解析成Python字典。但我从来不会只依赖它,后面会细说为什么它偶尔会是None,必须保留手动解析的分支。

3.3 为什么必须二次校验

很多读者可能觉得,既然在请求里已经挂了schema,模型不就应该严格按schema输出吗?理论上如此,实际操作中还是会出现意外。

情况之一是SDK解析失败。response.parsed在有些版本中偶尔会返回None,这时候你不能崩,得用response.text手动解析。情况之二是模型生成了超出schema约束的值——虽然概率很低,但比如confidence字段返回了字符串“0.8”而不是数字0.8;priority字段返回了"normal"但你的枚举里只有LOW/MEDIUM/HIGH/URGENT。这些边界问题不常见,但一旦混进生产数据,下游消费方很难察觉。

所以我在代码里用jsonschema库的validate函数做独立校验,这跟Gemini内部约束不是一回事,等于在系统边界上再放一道护栏。校验失败时,我的策略是把工单直接降级给人工处理,而不是让异常数据流进工单系统。

3.4 超时、重试与调用安全

LLM接口是外部依赖,跟本地函数返回完全不一样。调用可能超时、可能被限流、可能因为网络抖动失败。我当时做了一个简单的指数退避重试,最多重试3次:

import time def route_ticket_with_retry(issue_text: str, max_retries: int = 3): for attempt in range(max_retries): try: return route_ticket(issue_text) except Exception as e: if attempt == max_retries - 1: # 彻底失败时降级为人工工单 return { "target_team": "GENERAL_SERVICE_DESK", "category": "AI路由异常,需人工介入", "priority": "MEDIUM", "confidence": 0.0, "requires_human": True, "reasoning": f"模型调用失败或校验失败:{str(e)}" } time.sleep(2 ** attempt)

还有一个经常被忽略的点:工单内容往往包含用户姓名、邮箱、手机号、工单编号等个人信息。把原始工单直接传给外部API,从数据安全和隐私保护角度都不合适,我在发送之前做了一层脱敏,用正则替换掉常见的姓名/邮箱/手机号/工单号,等模型返回结果后,再在原工单UI上展示给操作员。

4. 实测效果与踩坑复盘:三种翻车和两条调优经验

4.1 第一轮上线时的三个典型翻车样例

我拿了一个月的历史工单做回放测试,第一轮的结果并不好看。我整理了一部分有代表性的错误,做成了一张表,方便你看清楚问题出在哪、如何修复。

模型实际返回问题现象根因分析修复方式
target_team: "ACCOUNT-TEAM"带连字符,不影响人类阅读,但程序精确匹配失败模型在枚举值上做了自由发挥,或受外部文档影响枚举值统一用无连字符大写,并在prompt里强调“必须原样返回枚举字符串”
target_team: "NETWORK_SUPPORT"返回到一个不存在的团队名枚举定义里没有覆盖到这个案例,模型试图“合理外推”增加GENERAL_SERVICE_DESK兜底枚举,并在路由手册里给出足够覆盖的团队职责说明
confidence: "中"置信度字段返回中文字符串schema里写的是STRING类型,模型按文本去填了将类型改为NUMBER,并在description里明确写“0.0到1.0之间的浮点数”

前两个问题本质相同:模型在不确定时会倾向于“补全”或“扩展”你给的信息。最好用的修复方式不是反复在prompt里强调,而是从schema层面堵住可能性:枚举值只给合法值,同时一定要给一个兜底值,让模型在不确信时有一个“合法出口”。

4.2 分类不均衡与“惯性路由”

回放测试还暴露了一个很典型的问题:当时工单量最大的是一个通用服务台,大概占了所有工单的50%。模型在处理模糊工单时,似乎有“惯性路由”的倾向,判断不出方向时会把工单丢给这个占比最大的团队,直接导致该团队错分率上升。

我做了两件事纠正它。第一,在prompt里明确写了一条规则:“无法确定路由方向时,将target_team置为GENERAL_SERVICE_DESK,同时把requires_human置为true,不要为了凑答案硬选。”第二,增加了一个判断阈值:当模型的confidence低于0.6时,查看requires_human是否为true,若false则强制转人工复核。

调完之后用同一批测试集做了对比:改动前那个大组的误分率约18%,改动后降到了11%,模糊工单的兜底比例也从原来的7%上升到12%,说明模型更愿意“承认自己不确定”了。这是好事——让模型承认不确定,比让模型假装自信强得多。

4.3 延迟与成本的结构性记录

这部分是给想上生产的读者一个具体参考:我用的是Gemini 2.0 flash模型,temperature为0,单次调用的平均延迟大约在1.5到2.5秒之间。对于工单路由这种异步场景完全够用,不需要走流式接口。

成本方面,在每天几百次调用的量级下非常便宜。但还是建议做两个优化:

一是针对重复工单做缓存。运维工单里经常出现“一模一样”或“高度相似”的描述,我按工单描述做了SHA1指纹,命中缓存时直接返回上次的路由结果,不用再调模型。

二是max_output_tokens要设置一个合理值,但是不要调太小的字节数。曾经我把单次返回token限制压得很低,结果reasoning字段被截断成半句话,导致JSON直接解析失败。路由场景的reasoning不会太长,512到1024这个区间足够用。

5. 落地闭环:从“能工作”到“可运维”的最后一公里

5.1 路由动作执行器

模型输出校验通过后,接下来的事才是真正影响业务的部分:怎么把“分给哪个团队”这个结果落进工单系统。我封装了一个执行器,把更新工单字段、指派责任人、触发通知这三个动作放在一个事务里执行,并且保证幂等:

def apply_routing(ticket_id: str, payload: dict): target_team = payload["target_team"] priority = payload["priority"] requires_human = payload["requires_human"] # 检查工单是否已经分派过,避免重复执行 if is_ticket_routed(ticket_id): return if requires_human: update_ticket(ticket_id, status="PENDING_HUMAN", note=payload["reasoning"]) else: update_ticket(ticket_id, assignee=target_team, priority=priority, status="ASSIGNED") send_notification(target_team, ticket_id, payload["category"])

幂等这块非常关键。我们的工单系统偶尔会因为消费端重试导致同一个ticket_id被处理两次,如果不做检查,会出现同一条工单被分给两个不同团队的操作。一个简单的is_ticket_routed判断就解决了这个问题。

5.2 灰度上线和一致性评估

没有哪个团队能一键切换到全自动模式还很安全。我的建议是先做影子模式,也叫shadow模式,具体做法是:

工单进来后,AI路由和人工分单同时跑,但AI的结果只写日志,不真正生效。人工分单结果作为“标准答案”和AI结果对比。跑一周之后,统计两者的一致率。

我当时给自己定的标准是:一致率达到85%就尝试放开到50%流量,稳定后全量放开。这个数字可以按你团队实际情况调整,但背后的思路是对的——所有模型决策都要经过“和人类判断比对”这一关,再逐步提高自动化比例。

影子模式还有一个额外好处:人工复核后的正确工单,会沉淀成新的样例。我每周会把新增的正确路由样例整理出来,挑几条有代表性的回填进prompt里的路由手册,形成一个正循环。

5.3 这套模式的迁移价值

最后想说的是,这种“LLM + JSON Schema + 二次校验 + 人工兜底”的结构,不止适用于工单路由。邮件自动分拣、测试缺陷自动分类、客户反馈归因、文档标签生成——本质都是一样的:让模型在受限结构里做语义判断,而不是自由发挥。

关键思维是把模型当成一个非常擅长阅读理解的解释器,而不是“智能体”。你给它一份清晰的协议,给它可以选择的枚举值,再给它一个承认不确定的出口,它就能在生产链路里当好一个可靠组件。我在实际推进过程中最大的体会是:与其反复调prompt,不如花时间把误分样本整理出来,对症下药去改schema里的枚举描述和组织结构。改一次schema,往往顶得上改十次提示词。

最后分享一个小技巧:每次路由出错,不要只看错误本身,把同一类型的人工处理结果拉出来做一次聚类,你会发现80%的误分都来自少数几个“模糊死角”。把这些死角显式写进路由手册或单独建一个兜底规则,整个系统的下限会很快被抬起来。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进