进阶:结构化
象信一号在训练时就大量接触结构化输入,它能读懂 JSON 的键名和嵌套关系。不仅 state 可以是对象或数组,问题本身也可以。
哪些地方可以用结构
下面每一个位置都接受字符串、JSON 对象或数组(数组元素可以是文本或嵌套结构):
| 位置 | 适用类型 | 说明 |
|---|---|---|
instructions | Choice / Score / Noul | 问题本身,以及它引用的数据 |
criteria 中每个选项的值 | Choice | 选项描述;不需要时可为 null |
criteria 数组中的每一项 | Score | 档位描述 |
criteria.true / criteria.false | Noul | "是"和"否"的定义 |
对象里的字段名由你决定,API 没有保留字。模型会同时读到字段名和字段值,所以字段名本身要能说明后面是什么内容,例如 question、定义、不包括、例子。
什么时候该结构化
- 为了清晰。 一个问题有好几个组成部分时(问题本身、判断依据、边界说明),用带标签的键把它们分开,比挤在一句话里更清楚。
- 问题需要附带数据。 数据库的一行、一个字段定义、一棵类目树本来就是 JSON,直接把它(或其中相关的子字段)放进去,而不是先拼成字符串模板。
如果一句话就能说清楚,就用字符串。结构化是用来解决"说不清"的问题的,不是默认写法。
结构化 instructions
最常见的形式是:问题放在一个字段里,它引用的数据放在其他字段里,并在问题中用反引号写出字段名,和引用 state 字段的写法一样。
下面是一个"字段抽取结果核验"的例子:上游系统(正则或大模型)从一张发票里抽出了若干字段,现在要逐个核对。一个 field 对象描述被核对的字段,几个问题都通过键名引用它:
from xiangxin import Choice, Noul, Score
field = {
"name": "购买方名称",
"extracted_value": "杭州某某科技有限公司",
"definition": "发票上「购买方」一栏的单位全称",
}
questions = {
"value_supported": Noul(
instructions={
"field": field,
"question": "state 中的发票是否支持 `field.extracted_value` 作为 `field.name` 的取值?",
},
),
"best_candidate": Choice(
instructions={
"field": field,
"question": "以下哪个候选值最符合 `field.definition`?",
},
criteria={
"杭州某某科技有限公司": "候选 A(来自购买方栏)",
"上海某某贸易有限公司": "候选 B(来自销售方栏)",
"not_found": "发票上找不到该字段",
},
),
"evidence_clarity": Score(
instructions={
"field": field,
"question": "发票中支持 `field.extracted_value` 的证据有多清楚?",
},
criteria=[
"找不到任何支持的文字",
"有相关文字但需要推测",
"原文逐字出现,位置明确",
],
),
}在真实代码里,你可以对每个待核字段循环生成这样一组问题,一次请求全部发出。问题文本是固定的,只有 field 在变。
数组也可以用。当问题是"一组要逐项核对的东西"时尤其自然:
Noul(
instructions=[
"合同是否同时包含以下全部条款?",
"违约金条款",
"争议解决条款(仲裁或诉讼)",
"保密条款",
],
)提示
上面这个问题同时检查了三件事,只适合"全部满足才算通过"的场景。如果你需要知道缺了哪一条,就拆成三个 Noul,一条一个。
结构化 Choice 选项
用 JSON 说明选项边界
两个选项很像、模型老是混淆时,给每个选项写一个对象:它包括什么、不包括什么(应该归到哪个邻居),再附上几个真实输入的例子。这就像给新同事的分类手册:光有类目名不够,还要有"易混淆情况说明"。
from xiangxin import Choice
intent = Choice(
instructions="用户这条消息属于哪类诉求?",
criteria={
"invoice_request": {
"包括": "申请开票、补开发票、修改开票抬头或税号",
"不包括": "询问发票是否已寄出、查快递 → 归 invoice_status",
"例子": ["帮我开个专票", "抬头写错了能重开吗"],
},
"invoice_status": {
"包括": "已申请开票,询问进度、寄送、电子发票下载",
"不包括": "还没申请、要新开 → 归 invoice_request",
"例子": ["发票开好了吗", "纸质发票寄出了没"],
},
"other": "与发票无关",
},
)逐层走一棵类目树
对很深的分类体系,每一层问一个 Choice,在代码里沿着树往下走。每一步的选项是当前节点的子节点,选项的值就是该子节点下面的子树。这样模型在做出选择前能"看到"每个分支下有什么,避免因为一级类目名字太笼统而走错路。
from xiangxin import Choice
TAXONOMY = {
"运动户外": {
"骑行装备": ["骑行水壶", "水壶架", "骑行头盔"],
"户外露营": ["帐篷", "睡袋", "户外水袋"],
},
"家居厨具": {
"水具酒具": ["保温杯", "塑料水杯", "玻璃杯"],
"厨房收纳": ["调料架", "保鲜盒"],
},
}
first_level = Choice(
instructions="这个商品应该归入哪个一级类目?",
criteria=TAXONOMY, # 每个选项的值是它下面的完整子树
)假设商品标题是"750ml 挤压式运动水壶,适配公路车水壶架":它既可能是"运动户外 → 骑行装备 → 骑行水壶",也可能是"家居厨具 → 水具酒具 → 塑料水杯"。把子树展示出来,模型可以权衡标题里"适配水壶架"这一强信号。
选定一级类目后,用它的子节点作为下一题的选项、孙节点作为值,重复直到叶子节点。在代码里就是一个遍历嵌套字典的循环,每一层的 criteria 就是当前节点。这是"第二个请求确实依赖第一个答案"的典型情形。
注意
一个 Choice 最多 255 个选项,而 state 加最长的单个问题不能超过 32k token。如果子树很大,只展开一两层,或者只保留子节点名称,不要把整棵树塞进每个选项。
结构化 Score 档位
Score 的每一档也可以是对象。各档使用相同的字段名,这样模型更容易对照:
from xiangxin import Score
answer_quality = Score(
instructions="客服这条回复在多大程度上解决了用户的问题?",
criteria=[
{"定义": "答非所问或给出错误信息", "例子": ["用户问退款,回复在介绍新品"]},
{"定义": "方向对,但缺关键步骤,用户还得再问", "例子": ["只说「可以退」,没说怎么退"]},
{"定义": "完整解决,用户无需追问", "例子": ["给出退款入口、时限和到账时间"]},
],
)例子只有在像你的真实输入时才有用。和业务无关的例子,效果通常与纯文本描述差不多。
结构化 Noul 标准
Noul 的 criteria 是可选的。当"是"与"否"的边界比较微妙时,用结构化的 true / false 分别给出定义和两边的例子,把边界钉死:
from xiangxin import Noul
is_complaint = Noul(
instructions="这条消息是否属于正式投诉?",
criteria={
"true": {
"定义": "明确表达不满并要求处理、赔偿或追责",
"例子": ["我要投诉你们的配送员", "不给个说法我就找 12315"],
},
"false": {
"定义": "只是咨询、抱怨或建议,没有要求处理",
"例子": ["今天送得有点慢啊", "建议你们多加几个自提点"],
},
},
)在示例中放"少样本"
在 criteria 的描述里放几个例子,本质上就是少样本(few-shot)提示。几条经验:
- 挑边界样例,而不是典型样例。 典型样例模型本来就判得对,边界样例才是它需要的信息。
- 两边都给。 只给"是"的例子,模型容易把相似但应判"否"的输入也拉过去。
- 用真实数据。 从你的历史工单、日志里挑,而不是凭空编写。
- 留出检验集。 用来写例子的样本不要再用来评估效果,否则会高估改进幅度。
引用:把问题指向正确的数据
无论数据在 state 里还是在 instructions 里,都用反引号加路径来引用:
- state 里的字段:
`order.items[0].name` - instructions 对象里的字段:
`field.extracted_value`
引用越明确,模型越不需要"猜"你指的是哪部分内容。间接层次越多("某个字段的某个属性所指向的那条记录"),准确率越低;能在代码里先查好、直接放进问题的,就不要让模型去跳转。参见 已知短板 与 如何用象信构建。

