偏好解析重中之重,一旦错误,全部完蛋

在整个货运调度 Agent 里,偏好解析其实是一个非常核心的问题。

如果没有偏好,司机的决策目标其实相对直接:搜货、计算收益、选择净收益高的订单。 但是一旦加入司机偏好,问题就会变复杂很多,因为偏好不是结构化规则,而是自然语言:

我这人熬不住连轴转,每天至少连续停车熄火休息满8小时。
不接货源品类为「化工塑料」或「煤炭矿产」的订单。
接单前去装货点的空驶距离不能超过55公里。
三月十二号要到增城老档口停两个小时盘点。
这个月怎么也得抽两天完全不动车休息。

这些句子背后对应的是完全不同的约束类型:

  • 有些是时间窗口,比如每天 0 点到 8 点必须休息;
  • 有些是货源过滤,比如某些货类、某些区域不能接;
  • 有些是空间约束,比如空驶距离不能超过 55km;
  • 有些是一次性任务,比如某天必须到某个地点停留;
  • 有些是月度累计目标,比如整月休息天数、到访次数、空驶预算。

所以我们想过直接上 Few-shot,但是这样很容易出现两个问题:

  • 不同类型的偏好对于小模型来说影响很大,对于 input 进来的偏好种类,如果与 shot 中的偏好种类大多都不相关,那么这个只能是噪声,会极其严重的影响模型偏好解析的能力。
  • 多 shot 会导致 input 的 token 大量增加,大大增加 token 的消耗,并且增加 latency。

因此我们最终敲定方案,用 RAG + Few-shot 来做,至少保证 Prompt 中的 shot 都和当前解析的偏好相关,在后续的实验中也证明了这一点想法。

1. 为什么偏好解析这么重要?

在前面的实验里我们发现,偏好对最终收益的影响非常大。尤其是偏好惩罚很重的司机,月收入竟然是负数。

货源收益通常是几百到几千,但是偏好罚款也可能是几百到几千,甚至有些偏好是月度封顶罚款。如果 Agent 只看货源收益,不考虑偏好,那么很容易出现这种情况:

某单收益:2000
违反每日休息偏好罚款:3000
最终净收益:-1000

也就是说,我们不可能不管偏好,这是这个项目中的核心处理点之一。

我们最开始也尝试过让主决策 Agent 直接读取原始偏好文本,然后自己判断是否要接单。但是这很快暴露出问题:

  • LLM 知道偏好存在,但不一定真的遵守;
  • 很多偏好需要跨时间追踪,单步 prompt 很难判断;
  • 偏好窗口和订单执行时间可能发生 overlap,模型容易漏算;
  • 多条偏好同时存在时,模型容易 lost in middle;
  • 线上失败后很难定位到底是偏好没解析对,还是决策没执行对。

因此,我们把偏好模块从主决策里拆出来,做成偏好解析 Agent,让它单独负责,可以具体分析成功案例和 Badcase。

偏好解析模块从主决策中拆分出来
偏好解析模块从主决策中拆分出来

2. 第一阶段:规则解析

最早的一版偏好解析很朴素,就是用规则去提取关键词。

例如:

不接 + 化工塑料 -> cargo_category_ban
不去 + 深圳 -> region_ban
每天 + 休息 + 8小时 -> daily_rest
空驶 + 不超过55公里 -> pickup_deadhead_limit

这种方式简单又稳定,对于一些简单的偏好够用了,但是偏好一旦开始复杂起来了,比如一段偏好自然语言中包含多步需求或者表意模糊,解析成功率很低,没覆盖到的关键词就完全解析不了。

比如下面这些表达,基本上覆盖不到:

我熬不住连轴转,晚上必须歇够。
爹妈在老家盼着,这个月得抽几天回去陪陪。
那批龙门吊底座死沉,别再给我派这种活。
三月十二号上午我得去增城老档口盘点,别给我耽误了。

3. 第二阶段:偏好解析(复杂版)

当时的目标是让 LLM 把自然语言偏好解析成一个完整的可执行计划:

这个结构里会包含很多字段:

字段含义对后续执行的作用
plan_kind偏好类型,说明这条偏好属于哪一类约束或任务决定后续走哪套执行逻辑,比如每日休息、区域禁接、货类禁接、指定地点停留、月度休息天数
modality约束模态,表示这条偏好是必须做、禁止做、限制做,还是只是倾向决定它会被处理成 hard block、soft penalty,还是 action/search bias
priority偏好优先级,表示这条偏好的强度决定违反时是否必须拦截,还是允许主决策在收益足够高时承担一定风险
time_window偏好生效时间或执行窗口判断当前是否处于偏好窗口内,以及候选订单执行时间是否会和偏好窗口冲突
anchors地理锚点,比如家、老档口、目标区域、禁行圆心给搜索、空驶、到访、停留任务提供目标位置,也可以作为 SearchAgent 的搜索方向
cargo_match货源匹配条件,比如货类、装货地、卸货地、路线区域、指定货源 ID让 CargoAnalystAgent 判断某个候选货源是否命中偏好规则
success_conditions偏好完成条件,比如连续休息 8 小时、到访 4 天、接到指定货源用于判断偏好是否已经完成、还差多少,以及是否进入高风险状态
action_plan为满足偏好需要执行的动作语义,比如 wait、reposition、search_cargo、take_order_matching给后续决策生成动作偏置,必要时转成 hard block 或候选行动
scoring_hooks从历史动作中更新偏好进度的统计钩子用于 Memory / progress 更新,比如统计休息时长、到访次数、空驶距离、违规次数
penalty违反偏好后的罚金金额和封顶信息把偏好风险折算到货源收益里,形成 risk-adjusted score
parse confidenceLLM 对解析结果的置信度,以及 unresolved 信息低置信度时进入 fallback、repair 或 trace 复盘,避免错误解析直接影响主决策

比如对于这条自然语言偏好:

不接装货地或卸货地在惠州的货,每接一次扣 800。

重解析希望输出类似:

{
  "plan_kind": "geo_order_rule",
  "modality": "prohibit",
  "priority": "economic",
  "time_window": {
    "start": "2026-03-01 00:00:00",
    "end": "2026-03-31 23:59:59"
  },
  "anchors": [
    {
      "type": "region",
      "name": "惠州"
    }
  ],
  "cargo_match": {
    "pickup_region": "惠州",
    "dropoff_region": "惠州",
    "match_logic": "pickup_or_dropoff"
  },
  "success_conditions": [],
  "action_plan": [
    {
      "op": "avoid_take_order_matching"
    }
  ],
  "scoring_hooks": [
    {
      "event": "take_order",
      "if_match": "cargo_match",
      "add_penalty": 800
    }
  ],
  "penalty": {
    "amount": 800,
    "cap": null
  },
  "parse": {
    "confidence": 0.96,
    "unresolved": []
  }
}

这一段偏好对后续执行链路的影响就是:

cargo_match
  -> CargoAnalystAgent 判断某个货源是否在惠州装卸

action_plan
  -> 提醒决策层 avoid_take_order_matching

penalty
  -> 如果硬接,则 risk_adjusted_route_net 减 800

scoring_hooks
  -> 历史里一旦接了惠州货,就记录一次偏好罚金

这个解析模式很好、很完整详细,但是问题也就是出在详细这里。这里有太多的东西需要模型去理解、判断好不好、然后还要生成对应的格式。模型很容易生成内容错误和格式错误。而且 加入 Few-shot 之后,input 明显变长,token 消耗太多。

实验结果也证明,它在 RAG + fewshot 的情况下,确实是具有一定的泛化能力,完全匹配率能达到 0.8,相较于 Static Few-shot 提升将近 20pp。但是有时候 retry 的次数太多,导致 latency 太长,并且对于复杂偏好(语义模糊+多步)还是不够准确。

既然 RAG + Few-shot 已经起到了很大的作用,我们就应该在轻量化偏好解析,让这个 Agent 不要承担如此多的字段解析,这能在 token 和 latency 大幅优化的情况下,提升一定的准确率。

4. 第三阶段:偏好解析(轻量版)

轻量解析的核心思想是:将偏好转化为更加确定的可执行语义

核心字段只有三个:

{
  "tasks": [],
  "filters": [],
  "counters": []
}

这三个字段分别对应三类偏好。

4.1 tasks:需要执行的偏好任务

tasks 表示司机在某个时间或地点需要执行的动作脚本。

比如:

每天 00:00-08:00 连续休息
3月12日到增城停留2小时
3月31日先去增城,再去四会,中午前到
某个指定货源上架后必须搜索并接单

这种偏好的类型是要求司机做一系列动作,比如 wait 多少时间、先去哪再去哪、要接哪个单,所以会被解析成 task。

一个每日固定休息的轻量表示大概是:

{
  "kind": "daily_fixed_rest_slot",
  "recurrence": "daily",
  "window": {
    "start": "00:00",
    "end": "08:00"
  },
  "steps": [
    {
      "op": "wait_until",
      "time": "00:00"
    },
    {
      "op": "wait",
      "duration_minutes": 480
    }
  ],
  "hard_block": true
}

它进入运行时之后,会被 PreferenceTaskExecutor 展开成具体某一天的分钟级窗口。如果当前时间处于这个窗口内,系统就会生成 hard block,阻止 search_cargotake_orderreposition 破坏休息。

4.2 filters:货源过滤规则

filters 表示对候选货源的过滤或者软惩罚。

比如:

不接货源品类为化工塑料、煤炭矿产、机械设备的订单。
装货地或者卸货地在惠州的货,我一律不接。
接单前去装货点的空驶距离不能超过 55 公里。
单笔货从装货点到卸货点的运输距离不能超过 150 公里。
我只在深圳市范围内跑车,车辆位置不能离开这个区域。
车辆不能进入以某个坐标为圆心、半径 5 公里的禁行区域。

这些都是对接货源的单有限制的,比如或货源品类、距离司机的距离、货源地处的位置等,因此我们把它抽象成对货源的一种过滤机制。

例如:

接单前去装货点的空驶距离不能超过55公里。

会被解析成:

{
  "kind": "pickup_deadhead_limit",
  "mode": "hard_filter",
  "max_km": 55
}

后面 CargoAnalystAgent 分析每一个货源时,会调用 PreferenceTaskExecutor.cargo_risk(),判断这个货源是否命中过滤规则。

如果是 hard filter,则这个货源不会进入可接单候选:

can_take_order = False
availability_state = blocked_by_preference

如果是 soft penalty,则会把偏好罚金折算进风险调整收益:

risk_adjusted_route_net
=
estimated_route_net
-
expected_preference_penalty

4.3 counters:长期偏好计数器

counters 用来处理跨时间的月度或累计偏好。

比如:

本月必须休息 2 个完整自然日
本月至少 4 天接触增城装卸货
本月空驶距离不能超过 100 公里
每天最多接 2 单
首单必须在早上 8 点前开始

这类偏好基本上都是每天/每月要求累积完成或者累积不超过的任务类型,这类偏好比较难以规划,需要用到 Memory 去记录偏好的履行情况。

所以 counters 会和 DriverMemory / preference_progress 结合,记录当前已经完成多少,还差多少,是否即将 missed。

例如月度休息天数可以表示成:

{
  "kind": "monthly_off_days",
  "required": 2,
  "strategy": "reserve full no-motion days this month"
}

运行时会根据历史动作判断哪些自然日满足了“完全不动车”,再把进度反馈给主决策 Agent。

5. RAG + few-shot 的设计

RAG few-shot 偏好解析链路
RAG few-shot 偏好解析链路

如果在 zero-shot 的情况下,Perference Agent 根本不会输出正确的格式。所以必须要 few-shot。

例如当前偏好是:

我熬不住连轴转,每天至少连续停车熄火休息满8小时。

RAG 检索到类似样例后,模型可以模仿样例,把它解析成:

daily_fixed_rest_slot
wait_until 00:00
wait 480 minutes

如果当前偏好是:

不接货源品类为化工塑料的订单。

模型可以模仿货类禁接样例,输出:

cargo_category_ban
mode = hard_filter
categories = ["化工塑料"]

RAG pipeline

我们尝试过以下的偏好解析和 RAG pipeline

  • none:不给 few-shot
  • static:给固定的 few-shot
  • BM25:BM25 关键词检索
  • bge-m3:向量检索
  • hybird:BM25 rank + bge-m3 rank,用 RRF 融合
  • hybird + rerank:RRF 候选池 后直接 reranker 重排(reranker model:bge-m3 reranker)

其中 RRF 的融合机制是:

RRF\text{score} = 1 / (60 + \text{rankbm25}) + 1 / (60 + \text{rankbgem3})

评测 & Badcase 分析

评测

这一部分主要分成两类评测:RetrieveParser

Retrieve 评测的是 RAG 检索阶段,也就是 few-shot 样例有没有找对;Parser 评测的是端到端解析阶段,也就是检索到 few-shot 之后,LLM 最终能不能把自然语言偏好解析成正确的 tasks / filters / counters

Retrieve:看 few-shot 是否找对,指标包括

指标含义关注点
Hit@KTop-K 检索结果里是否至少有一个相关样例判断检索器能不能召回可参考 few-shot
MRR@K第一个相关样例排名的倒数判断相关样例是否排得足够靠前
Type match@K检索样例的偏好类型和结构签名是否匹配判断找回来的样例是不是同一类偏好结构
Candidate Quality@K综合候选质量,主要考虑 CappedRecall、Precision、nDCG判断 Top-K 样例整体是否干净、相关、排序合理

其中 Type match@K 对这个任务比较重要。因为偏好解析不是普通文本问答,我们更关心的是样例结构是否相似。比如“每天 00:00-08:00 必须休息”和“每天 23:00-06:00 车辆不能动”,文本不完全一样,但结构上都属于每日固定休息/不动车窗口,这种样例对 parser 更有帮助。

Parser:真正调用 Agent,看是否能解析对

指标含义关注点
exact_core核心字段完全匹配的比例判断偏好类型、核心约束、关键动作是否都解析正确
avg_score多个检查项的平均得分比 exact 更宽松,反映整体解析结果和 gold 的接近程度
latency单条偏好完成检索和解析的耗时判断这个策略是否适合在线运行

其中 exact_core 是最严格的指标。只要关键结构错了,比如把“装货前空驶距离不能超过 55 公里”解析成“装货点到卸货点距离不能超过 55 公里”,就会影响 exact。avg_score 则会把偏好类型、时间、地点、动作、数值等拆开评分,所以更能反映“即使没完全对,离正确答案还有多远”。

不同 RAG 策略对 Parser 端到端解析效果的影响
不同 RAG 策略对 Parser 端到端解析效果的影响

第一张图看的是不同 RAG 策略对 Parser 端到端解析效果的影响。

Parser exact_core 来看,K 从 1 增加到 4-6 时,几种方法整体都有明显提升。这说明 few-shot 数量太少时,模型能参考的结构有限;增加到 4-6 条后,模型更容易看到相似偏好的写法,从而输出正确结构。BGE-M3 在 exact_core 上整体表现最好,K=5 左右达到峰值;BM25 和 Hybrid RRF 也比较稳定;Hybrid RRF + Rerank 在小 K 时明显偏弱,K=6 时追上来一次,但整体波动比较大。

Parser avg_score 来看,趋势和 exact_core 基本一致,但更加平滑。BM25、BGE-M3 和 Hybrid RRF 在 K=4-6 附近进入较高区间,说明即使没有完全 exact,很多字段也已经接近 gold。BGE-M3 在部分 K 上平均分更高,BM25 在 K=5 左右也很强。

Parser latency 来看,BM25、BGE-M3 和 Hybrid RRF 大多在 10-13 秒左右,而 Hybrid RRF + Rerank 的耗时波动更明显,部分 K 下明显升高。这说明 rerank 在当前设置下没有带来稳定收益,反而增加了延迟和不确定性。

所以这张图的结论是:few-shot K 不是越大越好,K=4-6 是比较合理的区间;BGE-M3 精度更强,BM25 更稳定,Hybrid RRF + Rerank 暂时不适合作为默认方案。

不同 RAG 策略的 Retrieve 检索质量对比
不同 RAG 策略的 Retrieve 检索质量对比

第二张图看的是 Retrieve 本身的检索质量。

Hit@K 看,随着 K 增大,所有方法都在提升。Hybrid RRF 的 Hit@K 最高,K=6 之后基本达到 1.0,说明它几乎总能在 Top-K 中召回至少一个相关样例。BGE-M3 也很接近,BM25 稍低但仍然稳定。

MRR@K 看,Hybrid RRF 和 BGE-M3 明显高于 BM25,说明它们不只是能召回相关样例,而且通常能把相关样例排在更前面。对于 few-shot parser 来说,前排样例更重要,因为 prompt 空间有限,模型更容易模仿靠前的样例(前置后置效应)。

Type match@K 看,Hybrid RRF 依然最好,说明它更容易召回同类型、同结构的偏好样例。这对偏好解析特别关键,因为 parser 需要的是“结构模板”,而不只是语义相似文本。

但是从 Candidate Quality@K 看,随着 K 增大,所有方法的候选质量都有下降。这说明 Top-K 越往后,越容易混入弱相关样例。Hybrid RRF + Rerank 下降尤其明显,说明当前 reranker 没有把结构更匹配的样例排得更好,反而可能引入了不够相关的样例。

所以这张图的结论是:Hybrid RRF 的召回和类型匹配最好,BGE-M3 的语义召回和排序也比较稳,BM25 虽然简单但速度快、稳定性好;当前 rerank 没有体现出收益。

综合两张图来看,我们最终更倾向于:

如果优先考虑线上稳定性和速度:BM25 + K=5
如果优先考虑解析精度:Hybrid RRF + K=5
Hybrid RRF + Rerank 暂时不作为默认方案

我们选择 Hybrid RRF + K=5 作为后续的实验基底。

Badcase 分析

原始偏好:

白云机场北边禁停严,我不去人和镇机场大道(23.34,113.29)周边2公里装货,卸货路过也别安排。

理想解析应该是圆形禁区过滤:

{
  "tasks": [],
  "filters": [
    {
      "kind": "outside_circle", 
      "mode": "hard_filter",
      "center": {
        "lat": 23.34,
        "lng": 113.29
      },
      "radius_km": 2.0,
      "applies_to": ["pickup", "dropoff"]
    }
  ],
  "counters": []
}

实际 LLM 有输出,但输出错成了:

{
  "tasks": [],
  "filters": [
    {
      "kind": "region_ban", # 应该是hard_filter
      "mode": "hard_filter",
      "regions": ["广州市人和镇机场大道周边"],
      "center": {
        "lat": 23.34,
        "lng": 113.29
      },
      "radius_km": 2.0
    }
  ],
  "counters": []
}

从这个样例中我们可以看出来,模型其实读懂了坐标和半径,但把“坐标 + 半径”的圆形禁区误归成了通用 region_ban,但其实 region_ban 只是针对某个地名或者坐标,而 outside_circle 才是会真正的去算一个圆的禁入范围。

所以对于这种 Badcase 来说,我们需要生成更多同类型的样例去补充我们的样例库,从而提升正确率。

few-shot 样例库怎么来?

首先,我们把官方提供的 10 个司机偏好做了一个初步的分类,比如有休息类偏好、货源禁接偏好、禁入限制、突发事件等。大概分类之后对简单类偏好(休息、禁入等用单动作就可以解决)做少量的扩增,对复杂类偏好(突发事件、月休等多动作多步骤事件)做多角度多样例的扩增,用不同的强模型(包括ds4、GPT5.5)生成多样的样例。

其次,每次一个完整的流程下来之后,会累积一些 Badcase,直接对 Badcase 进行分析,累积错误种类,有针对性的补充样例库和调优 Prompt,持续改进。

few-shot 样例库构建与扩增流程
few-shot 样例库构建与扩增流程

举个例子,对于复杂偏好

原偏好:

2026年3月10日10:00,家中急事:须先到(23.21,113.37)接上配偶(原地停留不少于10分钟),再返回老家(23.19,113.36);须在2026年3月10日22:00前进家门,到家后须在原处静止,至少待到2026年3月13日22:00事情解决方可再出车。

扩增偏好:

1. 2026年3月9日14:00先到(23.20,113.34)接父亲,原地至少等20分钟,然后送到医院(23.16,113.31),必须在当天17:30前到医院,到了以后车在医院附近停到18:30再出车。

2. 2026年3月11日上午10点前要先到(22.98,113.42)取证件,取完至少停5分钟确认,再去(23.05,113.48)交给客户,必须在12:00前交到。

3. 2026年3月18日13:00以后先把车开到修理点(23.08,113.27),至少检修90分钟,检修完再回停车场(23.11,113.30),最晚当天19:00回到停车场。

4. 2026年3月20日16:00先到(22.91,113.33)接孩子,等不少于10分钟,再回家(22.89,113.31)。必须在当天21:00前到家,到家后一直待到2026年3月21日7:00才能出车。",

整个数据库中偏好样例 629 条,其中简单偏好:复杂偏好 = 1:1

我们选择每个偏好类型的几条样例进行手工标注,之后作为标注 Few-shot 放入大模型自动标注,并给出置信度。全部标注完成后每个类型抽取10%人工检查,并且对低置信度的标注结果也检查。

对于生成偏好的自然语言本来就不可执行,直接丢弃该偏好;对于标注错误的偏好,人工校验修复标注。

这部分其实也能体现出我们这个模块的工程思考:RAG 的样例不是越多越好,而是要保证样例结构是正确的。错误样例进入 few-shot 库,反而会让模型稳定地学错。

6. 总结

相对于最开始直接把偏好文本塞进主决策 prompt,轻量偏好解析架构解决了几个问题。

第一点,对偏好 Agent 的输出要求降低,减少其工作量。 这对于较小参数模型(Qwen-flash)来说,是很好的优化。让输出的稳定性变高。

第二点,设计了累计类型的偏好执行模式 Counts,能有效处理每日累计/月度偏好。 比如月度休息天数、月度到访次数、月度空驶预算,都会进入 counterspreference_progress,不再完全依赖模型临场记忆。

第三点,偏好解析本身可以被评估和迭代。 我们有 RAG 检索指标,也有端到端解析指标,还能通过 badcase correction 扩充 few-shot 样例库。

同时这一部分我们对 BM25 和 Embedding 检索做了全面而且扎实的检测,包括性能,few-shot 的个数、延迟等。