1. 拆开“标签”这层外衣:它到底改变了什么
第一次看到Claude Tag这种写法的人,十有八九会皱眉头:不就是把提示词用尖括号包起来吗,能有多大差别。我最初也是这个判断,直到把一个纯文本解析的下游服务改成读标签结构,才发现问题的关键根本不在语法,而在于你给模型的到底是“一段话”还是“一份填报单”。这两者的差别,就像让同事口头汇报和让他填一张固定格式的表:前者你得反复追问,后者他填错了你一眼能看出来。
Claude Tag的核心思路是把上下文切成若干带名字的块,每个块承担一个明确职责。比如<role>放身份约束,<context>放背景资料,<examples>放参照样例,<output_format>放输出契约。模型在阅读时能清楚分辨“这段是让我扮演”、“这段是给我看的资料”、“这段是必须遵守的格式”,而不是把所有的文字混在一起当作一段长指令去猜。这个变化对长上下文场景尤其明显:当输入超过几千字时,没有标签的提示词很容易出现“后半段盖过前半段”的现象,加了标签之后,指令的权重反而更稳定。
我做过一个很粗糙的对比:同样一份三百行的日志,要求从中提取异常事件并给出等级。不加标签的版本,模型会把日志里的某些字段误当成指令执行;加了<log_data>包裹之后,误执行的概率基本消失。这不是模型变聪明了,而是你把“这是数据,不是命令”这件事用结构说清楚了。标签的本质是消歧义,不是给模型下咒。
1.1 三种标签形态与各自的适用场景
实际用下来,标签的用法大致能归成三类,混用会出问题,分清楚就顺了。
第一类是分区标签,作用是切分上下文。典型写法是把系统指令、检索到的资料、用户输入分别包起来。这类标签不需要模型输出任何东西,只影响它怎么“读”。分区标签最适合的场景是检索增强类的问答:你把召回的十段资料全塞进去,如果不分区,模型可能把某段资料里的句子当成你的要求。分区之后,你甚至可以要求它对不同分区给予不同信任级别。
第二类是示例标签,作用是给参照。把一正一反两个样例放进<examples>,比写五条“要求输出简洁”之类的形容词管用得多。我在做票据字段抽取时试过,纯文字描述的字段准确率明显低于给两个完整样例,尤其在字段含义模糊的地方,比如“金额”到底含不含税,一个样例就交代清楚了。
第三类是输出契约标签,作用是约束产出。让模型把最终结果包在<result>里,或者直接按给定 JSON 结构输出。这类标签是工程化的关键,因为它把“自由文本”变成了“可解析对象”。只要你能稳定拿到这个块,下游代码就能像处理普通接口一样处理模型输出。
注意:三类标签不要写在同一个层级里混着用。分区标签属于输入侧,示例标签属于参考侧,输出契约属于协议侧,混在一起会让模型的注意力分配变得混乱,尤其在小模型上表现明显。
1.2 一个最小可用的标签模板
下面这个模板是我目前用的起点,字段不多,但该有的都有。它可以跑在多种模型上,不绑定特定供应商。
<role> 你是一名日志分析助手,只做事实提取,不做推测。 </role> <rules> 1. 只依据 <log_data> 中的内容作答。 2. 字段缺失时填 null,不要编造。 3. 输出必须是合法 JSON,不要附加解释文字。 </rules> <examples> <example> <input>2024-05-01 10:22:31 ERROR db connection timeout after 3000ms</input> <output>{"level":"ERROR","component":"db","event":"connection_timeout","detail":"3000ms"}</output> </example> </examples> <log_data> {{RAW_LOG}} </log_data> <output_format> {"level":"string","component":"string","event":"string","detail":"string|null"} </output_format>这个模板有几个设计取舍值得说。<rules>放在示例前面,是因为规则属于强约束,位置越靠前越容易被遵守;示例放在规则后面,起到的是“具体化”作用。<output_format>放最后,紧贴模型开始生成的位置,能明显降低它忘记格式的概率。这些不是玄学,是上下文位置对注意力分布的实际影响,我在多个模型上都验证过类似规律。
另外,模板里所有标签名都用小写加下划线,不用驼峰也不用中文。原因很实际:下游解析器要写正则,命名风格统一能省掉一堆大小写兼容的破事。这个细节看起来无关紧要,但当你手上有二十个模板要维护时,统一命名规范能救命。
2. 使用门槛到底卡在哪:三层门槛逐层拆
聊门槛这件事,很多人上来就说“要会写提示词”。这个说法太含糊,也没法指导行动。我把它拆成认知、工程、成本三层,每层的难点和应对方式完全不同。搞清楚自己卡在哪一层,比盲目看十篇教程有用。
2.1 认知门槛:把对话当接口是最大的误会
最常见的问题是把模型当成一个“更聪明的搜索引擎”,问一句答一句,答得不好就换句话再问。这种用法在闲聊场景没问题,但一旦要接入业务流程,就会立刻撞墙:你今天调通了,明天换个输入就崩了,因为你依赖的是“这次它恰好理解了”,而不是“它必然按这个结构返回”。
我在给一个团队做内部分享时举过一个例子:让模型从一段客服对话里抽取“用户诉求”和“情绪倾向”。纯对话式的问法,十次里有六七次能答对,但格式五花八门,有的是“用户希望退款,情绪比较激动”,有的是分点列出。你没法写代码去解析这种东西。换成标签约束之后,第一次可能要调整三四轮模板,但一旦定下来,后面几百次的输出结构完全一致,下游直接json.loads就行。
这个转变的实质是:你要设计的不是一句话,而是一份接口约定。想清楚输入有哪些块、输出有哪些字段、字段类型是什么、缺失怎么表示,这些在写第一个字之前就该定好。很多人跳过这一步直接开写提示词,结果就是无限次微调,改到最后自己都不知道哪句是有用的。
这里还有个隐性门槛:你得能接受“模板是需要迭代的”。我见过有人写了一个模板,跑出来效果一般,就断定“这模型不行”。实际上模板迭代三次以上是常态,尤其字段多的时候,每次只改一个变量,看输出变化,这个过程跟调参数没区别,急不得。
2.2 工程门槛:解析、校验、兜底三件套
标签用得再漂亮,落到代码里还是得处理“模型没按你说的做”这件事。这是纯工程问题,跟模型能力无关,任何模型都会有失手的时候。我总结下来是三件套:解析、校验、兜底。
解析要能容忍脏数据。模型有时候会在 JSON 前面加一句“好的,以下是结果”,有时候会把 JSON 包在代码块里,有时候字段值里带换行。你的解析器如果只会json.loads(整段文本),那线上一定会出问题。稳妥的做法是先用正则把目标块抠出来,再做解析,解析失败再尝试修复常见的格式问题,比如去掉尾逗号、补全引号。
校验是第二道关。解析成功了不代表内容对:字段名对不对、类型对不对、枚举值在不在允许范围内、必填字段有没有缺。这些都要写显式检查,不能指望模型自觉。我一般会把 schema 定义成一份独立的配置,校验逻辑从配置生成,这样改字段只改一处。
兜底是最后一层。校验不过怎么办?三个选项:重试、降级、丢弃。重试要带上下文,把上次的错误信息回填给模型,成功率会高不少;降级是返回一个默认结构加标记,让下游知道这条数据不可信;丢弃只适合非关键链路。这三种策略选哪个,取决于你的业务能容忍多大的空洞。
import json, re def extract_block(text, tag="result"): m = re.search(rf"<{tag}>(.*?)</{tag}>", text, re.S) return m.group(1).strip() if m else None def safe_parse(raw): if raw is None: return None raw = raw.strip() raw = re.sub(r"^```(?:json)?|```$", "", raw, flags=re.M).strip() raw = re.sub(r",\s*([}\]])", r"\1", raw) # 去尾逗号 try: return json.loads(raw) except json.JSONDecodeError: return None这段代码不长,但每一行都是踩出来的。去尾逗号那行看着多余,实际上模型生成多字段对象时出现尾逗号的概率不低,尤其是字段数超过六个之后。
注意:不要试图用“更强的提示词”彻底消灭格式错误。格式错误率可以从百分之十几降到百分之一二,但降不到零。留好兜底,比追求完美模板划算得多。
2.3 成本门槛:一笔可以提前算清的账
标签会让输入变长,这是绕不开的。同样一份资料,加了标签和规则说明,输入 token 可能多出两三成。有人因此觉得不划算,但账不能这么算。
假设单次请求输入 1200 token、输出 300 token,日调用量 5 万次。设每百万输入 token 单价为 P,输出单价通常更高,取 3P 做保守估计。那么日成本约为:
输入:50,000 × 1200 / 1,000,000 × P = 60P 输出:50,000 × 300 / 1,000,000 × 3P = 45P 合计:约 105P如果标签让输入增加 30%,日成本变成 123P 左右,涨幅不到两成。但如果因为结构稳定,把重试率从 15% 降到 3%,省下的调用量就是 12%,几乎抵消了增幅。换句话说,标签带来的确定性,很多时候是能自己买单的。
真正要控制成本的地方不在标签本身,而在两处:一是别把整个知识库都塞进上下文,检索该做还是要做;二是能缓存的静态部分尽量缓存,<role>、<rules>、<examples>这些每次都不变,如果服务方支持前缀缓存,这部分能省掉一大截。我一般会把模板拆成“静态头”和“动态体”,静态头严格保持字节级一致,就是为了命中缓存。
还有一点容易被忽略:标签会让输出变长吗?一般不会,反而更短。因为格式约束明确之后,模型不需要再写“好的,我来帮你分析”这类客套话。输出 token 少个一两成是常见现象,这部分省下来的钱往往比输入增加的多。
3. 范式转变:从“写提示词”到“设计契约”
用标签用得久了,会有一个明显的感受:你干的活越来越像后端工程师,而不像“跟 AI 聊天的人”。这个转变不是虚的,它体现在三个方面:上下文怎么组织、交付物长什么样、团队怎么分工。
3.1 上下文的组织方式变了
早期的提示词写法是线性的,从头到尾一段话,靠“首先”“然后”“最后”这些连接词来分层。这种做法在短输入下能用,一旦输入变成几百行日志或者几千字文档,就彻底失效。模型读长文本时并没有真正的“从头读到尾”,它是对整段做注意力分配,线性写法里那些连接词的作用会被稀释。
标签带来的变化是把线性改成块状。每个块有边界、有名字、有职责,模型可以按块处理,你也可以按块调试。我现在的习惯是,模板里每加一个块,就问自己三个问题:这个块解决什么、能不能合并到别的块、去掉它效果会掉多少。第三个问题最有价值,因为模板膨胀是必然趋势,不定期做减法,最后会变成一坨谁也看不懂的东西。
块状组织还有个附带好处:可复用。<rules>里那几条“只依据提供资料作答”“缺失填 null”几乎是通用的,我把它抽成一个公共片段,二十个模板共用一份,改一次全生效。线性提示词做不到这点,因为每次都要重新组织语言。
3.2 交付物从“一段话”变成“一个字段集”
这个变化对下游影响巨大。以前模型输出一段话,得有人去看、去判断、去手工录入;现在输出一个字段集,直接入库、直接触发后续流程。差别不在于省了多少人力,而在于链路能不能自动化。
举个具体的:内容审核场景。以前的做法是模型输出一段评语,审核员看完再决定。现在可以让模型输出{"risk_level":"low|mid|high","categories":["..."],"reason":"..."},risk_level为 low 的直接放行,mid 的进人工队列,high 的直接拦截并附上 reason。整条链路的流转规则由字段决定,不由人的判断决定。这种设计下,模型输出的稳定性比“说得对不对”更重要,因为规则是围绕字段写的。
我踩过的一个坑是:一开始字段设计得太细,十几个字段,每个都想抓。结果模型在长尾字段上频频出错,校验失败率高得离谱。后来砍到四个核心字段,把细粒度信息塞进一个detail字符串里,失败率立刻降下来。字段数量是有上限的,超过五六个之后,出错率会明显上升,尤其在小参数模型上。
3.3 团队协作的分工跟着变
这一点在多人协作时特别明显。以前提示词是个人资产,谁写的谁维护,换个人接手基本等于重写。标签化之后,模板变成了可以评审的东西:产品看字段设计合不合理,后端看 schema 能不能对接,测试看样例覆盖够不够。它从“一段咒语”变成了“一份配置”。
我们内部的做法是,模板和 schema 一起进版本库,每次改动走 review,改动必须附带至少三条新增的测试样例。这个流程听起来重,但比线上出问题再回滚轻多了。有一次某人把一个枚举值从high改成了HIGH,没走 review,结果下游判空逻辑全挂,排查了两个小时。这种事故用流程是能防住的。
还有个变化是角色边界模糊了。写模板的人得懂点数据结构,写解析的人得懂点模型习性。我见过纯后端思维的人写模板,字段设计得很规范,但完全没考虑模型在歧义情况下的行为,跑出来一堆 null;也见过纯业务思维的人写模板,样例给得特别好,但字段命名前后不一致,解析器天天报错。两边各让一步,才是能用的东西。
4. 本地化落地的完整实操路径
把标签这套方法搬到私有环境里跑,难点不在标签本身,而在整条链路的可控性。下面按我实际推进的顺序讲,从选型到灰度,每一步都有具体的判断依据。
4.1 选型与环境准备
第一步是确定推理侧怎么提供。三种常见路径:调用外部服务、自建推理服务、混合。判断依据主要是三条:数据能不能出内网、调用量级、对延迟的容忍度。
数据敏感度高的场景,基本只能走自建。自建要考虑的不只是模型权重,还有并发能力。以标签类任务为例,单次请求输入一千多 token、输出几百 token,属于典型的短请求高并发。一台带单张中端加速卡的机器,跑参数量在中等规模的模型,并发做到个位数到十几路是比较现实的预期,具体数字跟量化方式、批处理策略关系很大,一定要自己压测,别看别人给的数字。
我一般的压测方法是:固定一份模板,构造五十条真实输入,循环跑,记录 P50 和 P95 延迟,同时看显存占用。批处理能显著提升吞吐,但会拉高单请求延迟,标签类任务对延迟敏感度不高,适当开批处理是划算的。这里有个经验值:批量大小从 1 提到 8,吞吐往往能翻几倍,再往上收益递减,而且 P95 延迟会明显变差。
混合路径适合过渡期:核心敏感数据走自建,非敏感的辅助任务走外部服务。这种架构的麻烦在于两套模板要维护,所以我建议一开始就把模板做成与供应商无关的形式,只依赖标签结构和 JSON schema,不依赖某家的特殊语法。这样换后端的时候,改动量能控制在一两天内。
环境上还有几个容易忽略的点。一是字符编码,日志和文档里中文、英文、符号混排很常见,统一 UTF-8 能避免一堆莫名其妙的解析错误。二是超时设置,标签类任务的输出长度可控,超时可以设得比通用对话短,早失败早重试。三是日志留存,每次请求的输入摘要、输出原文、解析结果都要留,出问题的时候这是唯一的排查依据。
4.2 标签体系与字段设计
这一步是整个落地里最花时间、也最值得花时间的部分。我给的建议是先画一张表,把“我要什么”写清楚,再翻译成标签。
| 设计项 | 要回答的问题 | 常见错误 |
|---|---|---|
| 分区划分 | 输入有哪几类内容,各自信任级别如何 | 全塞进一个块,模型分不清指令和数据 |
| 字段清单 | 下游真正需要哪些字段 | 贪多,十几个字段导致出错率飙升 |
| 类型约定 | 每个字段是字符串、枚举还是数组 | 用字符串装枚举,校验时无法穷举 |
| 缺失语义 | 字段没有值时怎么表示 | 一半填 null 一半填空字符串 |
| 置信度 | 要不要模型自评可信程度 | 要了但不用,白增 token |
字段类型这块多说两句。枚举是最值得用的,因为它把“自由发挥”限制在有限集合里,校验逻辑简单,统计也方便。数组适合一对多的关系,比如一篇文章的多个标签,但要注意空数组和 null 的区别,这个是下游最容易踩的坑。数值类型要慎用,模型对数字的精度控制不如文本,需要精确数值的场景,最好让它输出字符串再做转换。
标签命名我坚持三条:全小写、下划线分隔、语义唯一。语义唯一这条最容易被违反,比如同时存在<context>和<background>,模型会困惑该看哪个。合并同类项这件事,做的时候嫌麻烦,不做的时候更麻烦。
给一个我们实际在用的字段设计,做的是工单分类:
{ "category": "billing|technical|account|other", "priority": "P0|P1|P2|P3", "summary": "string, 不超过40字", "need_human": true, "reason": "string|null" }五个字段,覆盖了路由、排序、摘要、转人工判断四个需求。reason只在need_human为 true 时要求填写,其余情况为 null。这种条件依赖的设计能省 token,也能减少模型在无关字段上的发挥空间。
4.3 解析器与重试降级的代码实现
完整链路我拆成四步:调用、抠块、校验、处置。前面给过抠块和容错解析,这里补上校验和重试。
from enum import Enum CATEGORY = {"billing", "technical", "account", "other"} PRIORITY = {"P0", "P1", "P2", "P3"} def validate(obj): errs = [] if not isinstance(obj, dict): return ["not a dict"] if obj.get("category") not in CATEGORY: errs.append("bad category") if obj.get("priority") not in PRIORITY: errs.append("bad priority") s = obj.get("summary") if not isinstance(s, str) or not s.strip() or len(s) > 40: errs.append("bad summary") if not isinstance(obj.get("need_human"), bool): errs.append("bad need_human") if obj.get("need_human") and not obj.get("reason"): errs.append("reason required") return errs def run(text, call_model, max_retry=2): last_err = None for i in range(max_retry + 1): prompt = build_prompt(text, feedback=last_err) raw = call_model(prompt) obj = safe_parse(extract_block(raw)) errs = validate(obj) if obj else ["parse failed"] if not errs: return {"ok": True, "data": obj, "retry": i} last_err = "; ".join(errs) return {"ok": False, "data": None, "error": last_err}关键在feedback这个参数。重试时把上一次的错误信息回填到提示里,比如“上次category返回了refund,不在允许集合内”,成功率提升非常明显。我测过,首轮校验通过率大概七成多,带反馈重试一轮之后能到九成五左右,第二轮基本就是兜底了。
降级策略要提前定。工单分类这种场景,重试两轮还失败,我的做法是返回{"category":"other","priority":"P2","need_human":true},直接转人工,同时打点上报。宁可多转一次人工,也别让工单卡在系统里没人管。这个决策是业务方定的,不是技术方定的,一定要提前对齐。
注意:重试不要无脑复制原提示词。同样的输入同样的模板,第二次大概率还是同样的错。带上错误反馈,或者调整一下示例,才有意义。另外重试次数别超过两次,成本和延迟都不允许。
4.4 灰度上线与效果评测
上线这件事,最忌讳一次性全量切。我的做法是三步走:影子模式、小流量、逐步放开。
影子模式就是把新链路跑起来,但不影响线上结果,只记录它的输出,和现有方案做对比。这一步至少跑三天,覆盖早晚高峰和周末,因为流量的分布差异会暴露很多问题。对比方式我一般用人工抽检:每天抽两百条,人工标一遍,算出新链路的准确率和误判类型分布。误判类型比准确率更有用,它告诉你该往哪个方向调模板。
小流量阶段放开百分之五到十,重点看两件事:P95 延迟有没有超预算、兜底率有没有异常升高。兜底率是最灵敏的指标,它一旦从 5% 跳到 15%,说明有某种输入模式没被模板覆盖,赶紧去翻日志找样本。
评测集这件事值得单独说。我建议从上线第一天就建,每次发现错误样本就往里加,攒到两三百条的时候,它就是你最可靠的回归测试。改模板之前先跑一遍,改完再跑一遍,掉了几个点一目了然。这个东西前期投入大,后期回报极高,尤其是模板要改很多轮的时候。
| 阶段 | 流量比例 | 观察周期 | 核心指标 | 放行标准 |
|---|---|---|---|---|
| 影子 | 0% | 3 天 | 抽检准确率、误判类型 | 准确率不低于现有方案 |
| 小流量 | 5%-10% | 5 天 | 兜底率、P95 延迟 | 兜底率低于 8%,延迟达标 |
| 扩量 | 30%-50% | 5 天 | 业务侧投诉量 | 无明显上升 |
| 全量 | 100% | 持续 | 全链路监控 | 按常规运维 |
这套流程跑下来大概三周,比直接全量慢,但能避免大事故。我见过跳过影子模式直接全量的,上线两小时发现某类输入全部解析失败,回滚的时候又因为缓存问题拖了半天,损失比三周的时间成本大得多。
5. 高频问题与排查实录
线上跑久了,问题会呈现出明显的集中性,八成的问题就那么几种。把它们整理出来,新接手的人能少走很多弯路。
5.1 问题速查表
| 现象 | 可能原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 解析失败率突然上升 | 模型输出加了前缀说明 | 抽样看原始输出 | 解析器加前缀剥离;提示里强化“只输出 JSON” |
| 某字段大量为 null | 字段定义模糊或缺失语义不明 | 对照样例看该字段 | 补一个正例;明确“无值填 null” |
| 枚举值出现表外项 | 提示里没穷举允许值 | 统计出现的非法值 | 在<rules>里列出全部允许值 |
| 长输入后段信息被忽略 | 上下文过长,注意力稀释 | 截断测试定位临界点 | 分段处理;关键信息前置 |
| 输出被截断 | 输出长度上限不足 | 看 finish 原因 | 提高上限;拆分为多次调用 |
| 延迟毛刺严重 | 批处理或并发争抢 | 看 P95 和排队时长 | 限流;调整批大小 |
| 同一输入两次结果不同 | 采样温度过高 | 降温度做对比 | 结构化任务温度设低 |
| 中文标点导致解析失败 | 全角引号、全角逗号 | 检查输出里的符号 | 解析前统一替换 |
表里最后一条我单独拎出来说。中文场景下模型输出全角标点是常有的事,json.loads遇到全角引号直接报错,而且报错信息很难懂。解决办法是在解析前做一次符号归一化,把全角引号、逗号、冒号换成半角。这个处理逻辑很短,但能干掉相当一部分莫名其妙的失败。
5.2 几条踩过的心得
第一条:别在提示里写否定句。“不要输出解释文字”这种表述,效果远不如“输出必须以{开头、以}结尾”。模型对正向约束的遵守度明显高于否定约束,这在实际调试里差距很大。我现在写模板,凡是能用“必须是”表达的,就不用“不要”。
第二条:示例要一正一反。只给正例,模型不知道边界在哪;给一个错误示例并标注“这是错误示范”,效果提升明显。但反例不能给太多,一两个足够,给多了模型反而会模仿错误示例的格式。
第三条:模板稳定之后就别乱动。我见过有人手痒,隔两天就想优化一下措辞,结果每次改动都带来指标波动,最后连基线都没了。模板改动要有触发条件:出现新的错误模式、业务字段变更、换模型。没有这些触发条件,就别动。
第四条:把“模型不确定”这件事显式化。与其让模型在拿不准的时候硬编一个答案,不如在字段里加一个confidence或者允许unknown枚举值。我在工单分类里加了other这个类别之后,误判率下降了不少,因为模型有了一个体面的退路,不至于硬塞进某个不合适的类别。给模型留退路,比逼它答对更实用。
第五条:日志要存原文,不要只存解析结果。解析成功的结果看起来都一样,出问题的时候你需要的恰恰是那些没解析成功的原文。我们现在的做法是原文字段保留最近七天,抽样保留三十天,存储成本可控,排查时有据可查。
第六条:换模型必须重跑评测集。不同模型对标签的敏感度差别很大,有的对嵌套层级很敏感,有的对标签名大小写敏感。同一份模板换模型,表现可能差十几个点,不做回归就直接切,等于赌博。我一般会准备两三个候选模型,用同一份评测集打分,选综合成本和效果最优的,而不是盲目追新。
最后说个实际感受。这套东西刚上手的时候,会觉得限制多、麻烦,不如直接聊天来得快。但只要你的场景需要重复执行、需要下游对接、需要多人维护,那点前期的麻烦迟早要还。我现在写任何模板,第一步都是先想清楚输出字段,而不是先想怎么措辞,这个习惯改过来之后,返工率下降了一大截。至于标签的具体写法,各家模型都有自己的偏好,多试几次,找到那个让它稳定输出的形式,比背任何最佳实践都管用。