先讲个背景。工单路由这活儿我一直觉得是典型的“看起来简单、做起来复杂”的系统——每天几百张工单要把它们准确分给能解决的人效率和准确率都不能丢。我最近用 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 jsonschemaAPI密钥不要写死在代码里从环境变量读取这样在团队协作和部署时更安全。import os from google import genai from google.genai import types from jsonschema import validate, ValidationError client genai.Client(api_keyos.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( modelgemini-2.0-flash, contentsbuild_prompt(issue_text), configtypes.GenerateContentConfig( temperature0, top_p1, response_mime_typeapplication/json, response_schemaROUTING_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这里有几个关键点我想说明。temperature0是路由场景的必须设置。工单分派不是创意写作我们要的是确定性温度越低模型每次针对同一工单给出相同结论的概率越高。response_mime_typeapplication/json同样重要它告诉Gemini直接生成JSON内容不给Markdown包装。response.parsed是新版SDK提供的能力。当你在请求中传入response_schemaSDK可以自动把模型输出解析成Python字典。但我从来不会只依赖它后面会细说为什么它偶尔会是None必须保留手动解析的分支。3.3 为什么必须二次校验很多读者可能觉得既然在请求里已经挂了schema模型不就应该严格按schema输出吗理论上如此实际操作中还是会出现意外。情况之一是SDK解析失败。response.parsed在有些版本中偶尔会返回None这时候你不能崩得用response.text手动解析。情况之二是模型生成了超出schema约束的值——虽然概率很低但比如confidence字段返回了字符串“0.8”而不是数字0.8priority字段返回了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, statusPENDING_HUMAN, notepayload[reasoning]) else: update_ticket(ticket_id, assigneetarget_team, prioritypriority, statusASSIGNED) 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%的误分都来自少数几个“模糊死角”。把这些死角显式写进路由手册或单独建一个兜底规则整个系统的下限会很快被抬起来。