线路成本与 AI 因子修正

02 · 核心亮点

亮点二 · 图数据库 + LLM:AI 只输出修正系数

全项目最完整、最能体现工程判断力的案例。真正难的决策不是怎么调用大模型,而是怎么让大模型碰不到钱。

涉及模块:chain-cloud-ms-transport-service :18096 + chain-cloud-ms-aigc-service :18200

亮点二 · 图数据库 + LLM:AI 只输出修正系数

业务问题: 业务部门要求"根据实际情况给运输路线算一个更准的成本", 但没人能写清楚"更准"到底要考虑哪些因素——山区高速费高、 绕远路油费高、装卸效率、夜间行驶拥堵……这些经验都在老调度员脑子里。

于是我们做了一个判断:让 LLM 去做"经验判断",但绝不让 LLM 去做"算账"。

这是本项目最值得完整讲的一个案例,因为它同时包含:图数据库选型、 算法权衡、AI 工程化落地、以及一次真实的事故级踩坑。


一、业务场景

1.1 快递网络的物理结构

真实的快递网络是三级的,不是两点一线:

text
一级转运中心 (OLT)  ──干线──▶  一级转运中心 (OLT)     例:北京 → 上海
        │
        │ 支线
        ▼
二级转运中心 (TLT)  ──接驳──▶  网点 (AGENCY)          例:北京朝阳 → 通州
        │
        │ 接驳
        ▼
      网点 (AGENCY)

对应三种线路类型(TransportLineEnum):

类型连接业务含义
干线 TRUNK_LINEOLT → OLT跨城市干线,货车跑长途
支线 BRANCH_LINETLT → OLT区域内的集散
接驳 CONNECT_LINEAGENCY → TLT网点到转运中心,最后一公里

1.2 为什么这不能建在 MySQL 里

业务要回答的问题有三个层次:

  1. 单条线路的成本是多少? —— 这可以放关系库
  2. 从 A 到 B 该走哪条路径? —— 需要多跳搜索
  3. 如果中途要退回上一级重走,怎么算? —— 需要图上的邻居遍历

在 MySQL 里做多跳搜索,常规做法是递归 CTE:

  • 3 跳还能忍,5 跳就慢,10 跳基本不可用;
  • 而且每加一跳就要 JOIN 一次,边表被反复扫描;
  • 更根本的是:这个数据模型本来就不是"表 + 外键",而是"节点 + 有向边"。

所以选型是:Neo4j 存图,关系不落 MySQL。 这直接催生了 chain-cloud-ms-transport-service 这个没有 MySQL 的微服务。

1.3 业务的核心诉求:线路成本要"更准"

过去成本 = 里程 × 每公里单价。业务提的问题是:

"同样是 1000 公里,走京藏高速和走京昆高速成本能一样吗? 山区国道油耗高,高速费高,夜间跑长途效率低—— 我们能不能让系统自动把这些因素考虑进去?"

这就是 AI 介入的位置。而介入的方式,就是这个案例的技术核心。


二、方案设计

2.1 图模型

三类节点:

java
// chain-cloud-ms-transport-service entity/node/
@Node("AGENCY")  public class AgencyEntity extends BaseEntity {}   // 网点
@Node("OLT")     public class OLTEntity     extends BaseEntity {}   // 一级转运中心
@Node("TLT")     public class TLTEntity     extends BaseEntity {}   // 二级转运中心

节点公共属性(BaseEntity):

java
@Id @GeneratedValue private Long id;          // Neo4j 内部 id
private Long parentId;                        // 父节点(OLT 的 parentId 固定为 0)
private Long bid;                             // 业务 id(对应 MySQL 里的机构 id)
private String name; private String managerName; private String phone;
private String address;
private Point location;                       // 经纬度(Neo4j Point)
private Boolean status;
private String extra;                         // 扩展 JSON
public abstract OrganTypeEnum getAgencyType(); // 类型

两类有向关系(成对创建):

java
// TransportLineRepositoryImpl#create
String cypher = """
    MATCH (m:{}) {bid : $startId}
    WITH m  MATCH (n:{}) {bid : $endId}  WITH m,n
    CREATE
    (m) -[r:IN_LINE  {cost:$cost, number:$number, type:1, name:$name,
                     distance:$distance, time:$time, extra:'',
                     startOrganId:$startOrganId, endOrganId:$endOrganId}]-> (n),
    (m) <-[:OUT_LINE {cost:$cost, number:$number, type:1, name:$name,
                     distance:$distance, time:$time, extra:'',
                     startOrganId:$endOrganId,   endOrganId:$startOrganId}]- (n)
    RETURN count(r) AS c
    """;

// 标签从 @Node 注解反射拿到,不是硬编码
String firstType  = firstNode.getClass().getAnnotation(Node.class).value()[0];
String secondType = secondNode.getClass().getAnnotation(Node.class).value()[0];

为什么要建双向关系? 因为路径查询用的是有向模式 -[*..10]->。 只有双向边都存在,才能双向寻路。这是一个很容易被新人忽略的细节—— 只建单向边的话,返程的件就规划不出路线。

源码定位 chain-cloud-ms-transport-service/src/main/java/com/itheima/ms/transport/repository/impl/TransportLineRepositoryImpl.java:210-231

2.2 两种路径算法:跳数最少 vs 成本最低

业务配置项(存在 Redis,可热改):

java
// DispatchConfigurationServiceImpl
public DispatchConfigurationDTO findConfiguration() {
    String dispatchTime   = opsForValue().get("DISPATCH_CONFIGURATION:TIME");    // 默认 2 小时
    String dispatchMethod = opsForValue().get("DISPATCH_CONFIGURATION:METHOD");  // 默认 2 = 成本最低
    ...
}

分发逻辑:

java
// TransportLineServiceImpl#queryPathByDispatchMethod
@Override
public TransportLineNodeDTO queryPathByDispatchMethod(Long startId, Long endId) {
    // 调度方式:1-转运次数最少,2-成本最低
    Integer dispatchMethod = dispatchConfigurationService.findConfiguration().getDispatchMethod();
    if (dispatchMethod == 1) {
        return queryShortestPath(startId, endId);
    }
    return findLowestPath(startId, endId);
}

两种算法:

A. 跳数最少 —— 直接用 Neo4j 内置 shortestPath

java
// TransportLineRepositoryImpl#findShortestPath
String cypherQuery = StrUtil.format(
    "MATCH path = shortestPath((start:{}) -[*..{}]-> (end:{}))\n" +
    "WHERE start.bid = $startId AND end.bid = $endId \n" +
    "RETURN path", type, depth, type);

变量长度关系 [*..10] 让 Neo4j 用 BFS 搜最少跳数路径。

B. 成本最低 —— 全路径枚举 + 排序

java
// TransportLineRepositoryImpl#findLowestPath
String cypherQuery = StrUtil.format(
    "MATCH path = (n:{}) -[*..10]->(m:{})\n" +
    "WHERE n.bid = $startId AND m.bid = $endId\n" +
    "UNWIND relationships(path) AS r\n" +
    "WITH sum(r.cost) AS cost, path\n" +
    "RETURN path ORDER BY cost ASC, LENGTH(path) ASC LIMIT 1", type, type);

这段是本亮点最值得在面试里逐行讲的代码:

Cypher 片段作用
-[*..10]->变长关系模式,展开所有 ≤10 跳的路径
UNWIND relationships(path) AS r把每条路径上的关系炸开成行
sum(r.cost)按路径分组累加成本
ORDER BY cost ASC, LENGTH(path) ASC成本优先,跳数次优先(同成本时选跳数少的)
LIMIT 1只取最优路径

源码定位 chain-cloud-ms-transport-service/src/main/java/com/itheima/ms/transport/repository/impl/TransportLineRepositoryImpl.java:92-142

2.3 核心:AI 成本因子(Java 算账,LLM 只给系数)

这是整个方案的灵魂,也是最容易被面试官认可的一点。

第一步:Java 先算出确定的基准成本

java
// TransportLineServiceImpl#initMap
Coordinate origin      = new Coordinate(startOrgan.getLongitude(), startOrgan.getLatitude());
Coordinate destination = new Coordinate(endOrgan.getLongitude(), endOrgan.getLatitude());
Map<String, Object> params = new HashMap<>();
params.put("show_fields", "cost");          // 高德默认不返回 cost 字段,必须显式要
String driving = eagleMapTemplate.opsForDirection().driving(ProviderEnum.AMAP, origin, destination, params);
JSONObject jsonObject = JSONUtil.parseObj(driving);
Integer distance = jsonObject.getByPath("route.paths[0].distance", Integer.class);  // 米
Long    duration = jsonObject.getByPath("route.paths[0].cost.duration", Long.class);  // 秒

// 基准成本 = 距离(km) × 该线路类型的每公里单价
Double costPerKm = costConfigurationService.findCostByType(transportLine.getType());
Double baseCost  = NumberUtil.round(distance / 1000 * costPerKm, 2).doubleValue();

costPerKm 存在 Redis Hash 里,默认值:

java
// CostConfigurationServiceImpl
private static final Map<Object, Object> DEFAULT_COST = Map.of(
        TransportLineEnum.TRUNK_LINE.getCode(),   0.6,   // 干线 0.6 元/km
        TransportLineEnum.BRANCH_LINE.getCode(),  1.0,   // 支线 1.0 元/km
        TransportLineEnum.CONNECT_LINE.getCode(), 1.5);  // 接驳 1.5 元/km

第二步:把"人能理解的路线画像"发给 LLM

用 Java 17 的文本块拼了一段结构化的 Prompt:

text
请根据以下新建路线信息输出路线 cost 修正因子 JSON。

【路线基础信息】
- lineType: {lineType}
- startOrganId / endOrganId / startOrganName / endOrganName
- startOrganLevel / endOrganLevel        ← 机构级别:OLT / TLT / AGENCY
- startCoordinate / endCoordinate

【基础成本信息】
- unitCostByType: {unitCostByType}        ← 每公里单价
- baseCost: {baseCost}                    ← Java 已经算好的基准成本

【程序已知上下文】
- sameCity / sameProvince / crossProvince ← 同城/省内/跨省
- preCalculatedDistanceKm                ← 高德算的实际路网距离
- preCalculatedDurationHour
- straightLineDistanceKm                 ← Haversine 直线距离(绕路程度的分母)

要求:
1. 你可以自主决定是否调用高德 MCP 工具;
2. 如果已有输入足够,请减少无意义工具调用;
3. 你只能输出 JSON;
4. 不要输出 finalCost;          ← ★ 明确禁止 LLM 输出最终金额
5. 所有 factor 必须在合法范围内;
6. 信息不足时采用保守输出。

几个细节值得讲:

  • startOrganLevel 是用 firstNode.getClass().getSimpleName().replace("Entity","") 反射出来的,

所以输出的是 "OLT" / "TLT" / "AGENCY"。

  • straightLineDistanceKm 用 Haversine 算(DistanceCalculator,地球半径 6371km)。

它和路网距离的比值就是"绕路程度",这是判断 detour 因子的关键输入。

  • Prompt 里明确写了"不要输出 finalCost"。这不是客气,是契约约束。

第三步:LLM 返回 9 个乘性修正因子

CostFactorResponse 的字段:

因子业务含义为什么要它
regionFactor地区因子西部山区 vs 东部平原,人力和车辆损耗不同
distanceSegmentFactor距离分段因子短途装卸占比高,长途纯里程
efficiencyFactor效率因子装载率、回程货比例
tollFactor过路费因子高速 vs 国道的真实差异
detourFactor绕路因子山路绕行、河道阻隔
routeComplexityFactor路线复杂度因子立交、隧道、跨江桥
corridorFactor物流走廊因子成熟干线 vs 新辟通道
similarityCalibrationFactor相似度校准历史同类线路的对标
accessFactor接驳因子网点到高速口的最后一段
confidence置信度—— 收了但没用,见下文
usedTools用了哪些工具—— 也没用
reason理由(人话)✅ 写进了数据库 extra 字段

第四步:Java 侧做钳制,再乘上去

java
// TransportLineServiceImpl#initMap(第 159-191 行)
// 获取每一个因子,判断是否在 0.95 到 1.10 之间,不在则直接赋值 1.0
List<Double> factors = List.of(
        costFactorResponse.getRegionFactor(),
        costFactorResponse.getDistanceSegmentFactor(),
        costFactorResponse.getEfficiencyFactor(),
        costFactorResponse.getTollFactor(),
        costFactorResponse.getDetourFactor(),
        costFactorResponse.getRouteComplexityFactor(),
        costFactorResponse.getCorridorFactor(),
        costFactorResponse.getSimilarityCalibrationFactor(),
        costFactorResponse.getAccessFactor());

double multiplier = 1.0;
for (Double factor : factors) {
    if (factor == null || factor < 0.95 || factor > 1.10) {
        factor = 1.0;                       // ← 越界或空 → 回归中性值
    }
    multiplier *= factor;
}

// 乘积再做一次粗粒度保护:9 个 1.10 相乘可达 1.10^9 ≈ 2.36
if (multiplier < 0.85 || multiplier > 1.25) {
    multiplier = 1.0;
}

// 最终成本 = 乘积 × Java 算好的基准成本
double finalCost = NumberUtil.round(multiplier * baseCost, 2).doubleValue();
transportLine.setCost(finalCost);
transportLine.setExtra(costFactorResponse.getReason());   // 理由入库,可审计

源码定位 chain-cloud-ms-transport-service/src/main/java/com/itheima/ms/transport/service/impl/TransportLineServiceImpl.java:117-197 chain-cloud-ms-aigc-service/src/main/java/com/itheima/ms/aigc/service/impl/CostFactorServiceImpl.java:1-31

2.4 这个设计的三个层次(面试必须讲出来)

层次谁来做为什么
绝对值Java + 高德 API距离和时长是客观可测量的,必须精确、必须可复现
修正比例LLM这是主观经验,规则写不出来,规则一旦写出来又必然过时
边界约束Java(硬编码)AI 永远不能决定最终的数字范围

一句话总结这个设计:

让 AI 做它擅长的(模糊经验判断),把它不擅长的(精确计算)牢牢留在代码里, 并且在数据出口加一道硬边界。


三、难点拆解

难点 1:怎么定义"一条运输路线成本"里的那些因素

难点在哪: 这不是技术难点,是业务难点。 成本项拆得对不对,取决于能不能把老调度长的经验表达出来。 拆得太粗(比如只给"复杂/简单"两档)没有指导意义;拆得太细(20 个因子) 又难以维护,而且 LLM 一次输出 20 个因子,任何一个幻觉都会污染整体乘积。

怎么解: 选 9 个因子 是一个折中:

  • 上界:因子数量 × 钳制区间决定整体浮动范围可控;
  • 下界:覆盖了地区/距离/效率/费用/绕路/复杂度/走廊/相似度/接驳,

这 9 个维度基本能对应上调度员的经验分类。

并且用两层钳制把风险关死:

text
单因子 ∈ [0.95, 1.10]  → 9 个因子乘积理论范围 [0.95^9, 1.10^9] ≈ [0.63, 2.36]
乘积   ∈ [0.85, 1.25]  → 收窄到 ±15% / ±25%

代价(必须主动说): 整体浮动只有 ±25%,这意味着 AI 的影响是被刻意限制在小范围内的。 如果业务说"我们要让 AI 能把某条线的成本翻倍",这个设计就不适用。 限制范围是这个方案的前提,不是缺陷。

难点 2:图上搜索的复杂度

难点在哪: -[*..10]-> 会把起点到终点之间所有不超过 10 跳的路径全部枚举出来。 在一个有 N 个节点、平均度数 D 的图里,路径数量是 O(D^10)。 这不是性能优化的问题,是数量级爆炸的问题。

为什么当初还是这么写: 因为快递网络是分层且稀疏的—— OLT 全国也就一两百个,TLT 几千个,网点几万个, 但层与层之间只有一种连接方式(OLT→OLT / TLT→OLT / AGENCY→TLT), 实际路径数量远小于理论值。加上数据量小,初期能跑。

如果重做(这是我给出的最实际的一条建议):

不要在 Java 侧做加权最短路,把带权最短路下推给图数据库。

两种可选方案,按改造成本排序:

方案改法说明
APOC 插件(推荐)CALL apoc.algo.dijkstraWithDefaultWeightCost(...)一行 Cypher 换掉整个枚举,Neo4j 官方插件,社区最成熟
GDS 插件gds.beta.shortestPath.yens(...) / gds.beta.graph.bellmanFord.stream(...)图算法库,支持多目标、权重函数
纯 Cypher 的正确写法借鉴社区的 shortestPath 加权变体:先用无权 shortestPath 拿候选集,再在小候选集里算加权最短路不依赖插件,但需要两段查询

如果不需要插件,也可以退一步做受控搜索: 把深度从 10 降到 5(业务上超过 5 跳的线路基本不存在), 并在枚举前用 MATCH 限制中间节点只能是 TLT/OLT。 这样能把路径数量降 2~3 个数量级。

难点 3:机构类型识别(一个很"业务"的坑)

AuthMQListener 消费权限中心推送的机构变更消息(ADD/UPDATE/DEL), 需要根据消息内容决定写入哪种节点。而消息里没有机构类型字段。

现有实现的判断方式:

java
// AuthMQListener#resolveOrganType
private OrganTypeEnum resolveOrganType(String name) {
    // - xxx转运中心  →  一级转运中心(OLT)
    // - xxx分拣中心 →  二级转运中心 (TLT)
    // - xxx营业部  →  网点(AGENCY)
    if (name.endsWith("转运中心")) {
        return OrganTypeEnum.OLT;
    } else if (name.endsWith("分拣中心")) {
        return OrganTypeEnum.TLT;
    } else if (name.endsWith("营业部")) {
        return OrganTypeEnum.AGENCY;
    } else {
        // TODO
        return null;          // ← 返回 null,后面的 switch 会 NPE
    }
}

这个设计非常脆弱:

  1. 机构名是运维随手填的,改个字就挂了;
  2. 已经有 TODO,说明作者自己也发现了;
  3. 返回 null 后 switch (organTypeEnum) 直接 NPE。

README 里写的方案是"用 AI 判断机构类型"(Agent 1005), 但 Java 侧目前只字未提,Agent 1005 也没有实现。

如果重做: 优先级应该是

text
1. 权威源:权限中心的机构表里本来就有 orgType/orgLevel 字段,直接同步过来
2. 兜底:关键字匹配(保留现在的逻辑)
3. 再兜底:AI 分类(Agent 1005),返回 UNKNOWN 时走人工确认队列

关键是 UNKNOWN 必须是一个合法的、可流转的业务状态, 而不是一个 null 然后 NPE。 Agent 1005 的枚举定义里确实有 UNKNOWN,说明设计时想到了,代码里没落地。

难点 4:AI 因子在"零成本修正"和"乱调"之间怎么平衡

难点在哪: LLM 会非常自信地给出看起来很专业的因子。 比如它可能给一条普通国道返回 efficiencyFactor = 1.35("夜间效率低")。 如果直接用,成本就歪了 35%。

怎么解(三道防线,缺一不可):

防线位置作用
1. Prompt 约束文本块第 4、5、6 条"不要输出 finalCost"、"factor 必须在合法范围内"、"信息不足时保守输出"
2. 单因子钳制Java 循环 `if (factor == null \\
3. 乘积钳制`if (multiplier < 0.85 \\

第 2 条的精髓:逐个打回,而不是整体打回。 如果 LLM 只错了一个因子(过路费),其他 8 个因子仍然生效。 这比"发现越界就整体退化成 1.0"的方案信息利用率高得多。

还留了一条审计线索:

java
transportLine.setExtra(costFactorResponse.getReason());

把 LLM 说的理由存进 extra。业务如果觉得某条线的成本算得不对, 可以查这条记录看 AI 当时是怎么说的。

⚠️ 但这里有个真实 bug: create 的 Cypher 里 extra:'' 是硬编码的空串, 而且只写 cost/number/name/distance/time/startOrganId/endOrganId, 根本没 bind extra。所以这段理由在"新建线路"时存不进去, 只有走 updateLine 才可能进去。


四、实际遇到的困难与踩坑

坑 1:经纬度的 x/y 读写约定不一致(跨模块的隐性 Bug)

事实:

位置约定
BaseEntity.location 的 Javadoc"x: 纬度,y: 经度"
OrganUtils.toEntity 写入new Point(latitude, longitude) → x = 纬度
TransportLineRepositoryImpl 读取setLatitude(location.y) / setLongitude(location.x) → 假设 x = 经度
OrganRepositoryImpl 读取setLongitude(location.x()) / setLatitude(location.y()) → 假设 x = 经度
ServiceScopeEntity.polygon 的 Javadoc"x: 经度,y: 纬度"

写入按 x=纬度,读取按 x=经度 —— 经纬度全程互换。

更糟的是:pointInPolygon 的绕路因子计算直接用了这两个值:

java
double straightLineKm = DistanceCalculator.calculateDistance(
        startOrgan.getLongitude(), startOrgan.getLatitude(),
        endOrgan.getLongitude(),   endOrgan.getLatitude());

如果经纬度反了,算出来的"直线距离"就是错的, 喂给 LLM 的 straightLineDistanceKm 也就是错的, 进而导致 detour 因子判断失准。

这是个典型的"文档、写入、读取三方约定不一致"的经典事故。 面试时讲这个的价值在于:它证明你会去追一个数字为什么不对, 而不是"测试没报错就算对"。

修复方案: 统一约定为 x = 经度、y = 纬度(与 GeoJSON、PostGIS、 MongoDB 的 $geoIntersects 一致),改写入端,并给 TransportLineRepository 增加一个一次性坐标校验的启动检查:

java
// 启动时抽样校验:经度必须在 [-180,180],纬度必须在 [-90,90]
// 北京的经度 ~116.3,如果被当纬度存就是 116.3(>90),一眼看出问题

坑 2:Cypher 字符串拼接 = 注入风险

事实 1(安全): 路径查询用的是参数绑定,是安全的。

java
neo4jClient.query(cypherQuery)
    .bind(start.getBid()).to("startId")
    .bind(end.getBid()).to("endId")

事实 2(不安全): 分页查询用了字符串拼接。

java
// TransportLineRepositoryImpl(分页查询)
StringBuilder whereBuilder = new StringBuilder();
if (StrUtil.isNotBlank(dto.getName())) {
    whereBuilder.append(" AND r.name CONTAINS '").append(dto.getName()).append("'");
}
if (StrUtil.isNotBlank(dto.getNumber())) {
    whereBuilder.append(" AND r.number CONTAINS '").append(dto.getNumber()).append("'");
}
if (dto.getStartOrganId() != null) {
    whereBuilder.append(" AND r.startOrganId = ").append(dto.getStartOrganId());
}

name 和 number 是用户输入,直接拼进 Cypher。 这个是真正的 Cypher 注入(和 SQL 注入等价,可以拖库)。

事实 3(半安全): 机构查询用了 StrUtil.format 拼接, 但参数是 Long 类型,Java 侧已保证不是字符串:

java
// OrganRepositoryImpl#findByBid
var cypherQuery = StrUtil.format("""
        MATCH (n)
        WHERE n.bid = {}
        RETURN n""", bid);

这里 name 甚至只做了去引号处理(StrUtil.removeAll(name, '\'', '"')), 没有参数化也没有转义。

修复:统一改用参数绑定。

java
neo4jClient.query("MATCH ()-[r]->() WHERE r.name CONTAINS $name RETURN r")
    .bind(name).to("name")

坑 3:删除线路只删了一半

事实:

一条逻辑线路会创建 2 条 关系(IN_LINE + OUT_LINE), 但 id(r) 只有一个:

java
// create 里 RETURN count(r) AS c   —— count 的是 IN_LINE,返回 1

删除时:

java
MATCH ()-[r]->() WHERE id(r) = $lineId
DELETE r

只删了 IN_LINE,OUT_LINE 留在了图里。

代码里 Controller 的注解其实承认了这一点:

java
@ApiOperation(value = "删除路线", notes = "删除路线,单向删除")

为什么这不是一个纯技术 Bug: 图里的边是双向创建的,因为路径查询用有向模式。 删掉一条之后,这个方向的路径就规划不出来了, 但反向查询仍然"正常"——所以这个 Bug 会以 "有时候能查到路线,有时候查不到"的形式在生产上表现出来,极难定位。

修复:

java
// 一次删两个方向
MATCH (a)-[r:IN_LINE|OUT_LINE]-(b)
WHERE id(r) = $lineId OR {startOrganId: a.bid, endOrganId: b.bid} = ...

更好的方案是不要给关系做主键,用业务唯一键 (startOrganId, endOrganId, type) 做双向删除和更新。

坑 4:专线和临时线路会 NPE

事实:

java
// TransportLineServiceImpl#createLine
switch (transportLineEnum) {
    case TRUNK_LINE:   firstNode = new OLTEntity();    secondNode = new OLTEntity();    break;
    case BRANCH_LINE:  firstNode = new TLTEntity();    secondNode = new OLTEntity();    break;
    case CONNECT_LINE: firstNode = new AgencyEntity(); secondNode = new TLTEntity();    break;
    default: break;    // ← SPECIAL_LINE(专线) 和 TEMP_LINE(临时线路) 落到这里
}
...
Long count = transportLineRepository.queryCount(firstNode, secondNode);  // ← NPE

TransportLineEnum 定义了 5 种线路,但 switch 只处理了 3 种。 专线(任务城市到任意城市)和临时线路落到 default: break;, firstNode / secondNode 保持 null,紧接着就 NPE。

根因分析: 这反映了一个领域建模的断层—— 专线和临时线路在概念上不是"边": 专线是"为某次特定任务临时开的通道", 临时线路是"运营临时加的线"。 它们没有固定的起点终点组合,本来就不该塞进 IN_LINE/OUT_LINE 这对关系模型。 正确建模是给线路加一个生效时间窗(effectiveFrom / effectiveTo), 让同一对节点可以有"常态边"和"临时边"并存。

坑 5:成本改了,历史线路不重算

CostConfigurationServiceImpl#saveConfiguration 用的是 putAll:

java
// 只 putAll,不 delete
stringRedisTemplate.opsForHash().putAll(CC_TRANSPORT_COST_REDIS_KEY, costMap);

两个后果:

  1. 删不掉的旧数据 —— 运营下线了一个线路类型,Hash 里的旧 key 还在;
  2. 历史线路成本永远是旧值 —— 改了每公里单价,

Neo4j 里已有线路的 cost 不会更新。

第二条的业务影响很实在:运营调完单价,路径规划的排序完全不变, 业务会问"我明明改了配置怎么没效果"。

修复思路(两条路):

方案做法适用
成本快照线路的 cost 一旦生成就固化,另存一个 costConfigVersion财务口径要对账时用这个(推荐)
动态成本不存 cost,路径搜索时按当前配置实时算配置变动不频繁时可接受,但路径搜索会变慢

金融/财务口径必须用第一条——历史成本不能因为配置变了就变。


五、如果重做,我会怎么改

优先级改动收益
P0统一经纬度 x/y 约定为 x=经度,加启动期校验消除一类静默的数值错误
P0全量 Cypher 改参数绑定消除注入风险
P0resolveOrganType 兜底返回 UNKNOWN 而非 null,补齐专线/临时线路分支消除 NPE
P1findLowestPath 换成 APOC dijkstra 或深度降为 5消除路径数爆炸
P1给 aigcFeign.identifyCostFactor 加 fallbackFactory 返回全 1.0 因子AI 挂了不影响建线路(见下)
P1线路删除改双向;关系不用 id(r) 做主键消除"方向性丢图"
P2用 confidence 做阈值:低于 0.6 时只用 baseCost 并标记"待复核"让置信度真正参与决策
P2专线/临时线路引入时间窗,而不是塞进固定边领域模型正交

P1 里那条最值得展开讲

现状: AI 调用没有任何容错。

java
CostFactorResponse costFactorResponse = aigcFeign.identifyCostInfo(routeInfo);
// 没有 try-catch,没有 fallbackFactory,没有熔断

而超时配置是:

yaml
# chain-cloud-ms-aigc-service/src/main/resources/bootstrap.yml
feign:
  client:
    config:
      default:
        connect-timeout: 10000
        read-timeout: 1000000      # ← 1000 秒 ≈ 16.7 分钟

16.7 分钟的读超时。 这意味着 Python AI 服务挂掉时, 建线路这个接口会挂 16 分钟,然后超时失败。

修复(三层,从便宜到贵):

java
// 1. 专属超时:给 AgentServerClient 单独配,不跟其它 Feign 共用
@FeignClient(name = "chain-cloud-agent-server", contextId = "AgentServer",
             path = "/",
             fallbackFactory = AgentServerFallback.class)   // 2. 降级
public interface AgentServerClient {
    @PostMapping("/chat")
    String chat(@RequestBody ChatRequestDTO chatRequestDTO);
}
java
// 3. Feign 层超时只能兜底,真正的解法是把超时前置
//    在 Java 侧用 CompletableFuture.orTimeout(3, TimeUnit.SECONDS)
//    或用 Resilience4j 的 TimeLimiter 包一层
yaml
feign:
  client:
    config:
      default:
        connect-timeout: 3000
        read-timeout: 5000
      chain-cloud-agent-server:      # 按服务名单独覆盖
        connect-timeout: 1000
        read-timeout: 3000

降级实现(一行):

java
@Component
public class AgentServerFallback implements FallbackFactory<AgentServerClient> {
    @Override
    public AgentServerClient create(Throwable cause) {
        return dto -> {
            log.warn("AI 服务不可用,成本因子降级为中性值 1.0", cause);
            // 全 1.0 因子 → multiplier = 1.0 → finalCost = baseCost
            return JSONUtil.toBean("{\"regionFactor\":1.0,\"confidence\":0.0,\"reason\":\"AI_DEGRADED\"}",
                                   CostFactorResponse.class);
        };
    }
}

降级之后的行为是可解释的: multiplier = 1.0^9 = 1.0,finalCost = baseCost, 也就是退回到"距离 × 单价"这个最朴素的算法, 同时 reason 写成 AI_DEGRADED,运维一看就知道这条线是降级算的。

这一段是整个亮点里最值得反复打磨的回答。 它同时展示了:知道 AI 不可靠、知道降级要可解释而不是静默、 知道降级路径要能被运维发现。


六、面试问答

Q1:为什么让 LLM 返回"因子"而不是直接返回成本?

A: 三个原因,按重要性排:

  1. 可验证:因子有明确的合法区间,我可以在 Java 侧钳制。

直接返回一个数,我没有任何手段判断它是 1.23 还是 123000。

  1. 可组合:9 个因子各自独立,我可以在 Java 侧做统计、

做校准(比如用历史真实成本做线性回归修正整体偏置), 也能一眼看出是哪个维度在推高成本。

  1. 可降级:AI 挂掉时,全 1.0 的因子天然等于"不修正",

代码里 multiplier 初始化就是 1.0,降级路径不需要额外写。

追问:那 LLM 会不会直接返回一个 finalCost 忽略你的指令? 会,这是必须防的。所以我做了两件事: Prompt 第 4 条明确禁止,同时Java 侧的 DTO 里根本没有 finalCost 字段—— 就算它输出了,JSONUtil.toBean 也会直接丢掉。这是用类型系统做防御, 比用提示词做防御可靠得多。

Q2:9 个因子是怎么定出来的?

A: 这是业务难点不是技术难点。方法是这样的: 先找 3~5 个有 10 年以上经验的调度员做访谈, 把他们判断"这条线好不好走"时下意识会看的维度列出来, 整理、去重、合并,最后收敛到 9 个。

关键约束有三个:

  • 每个因子必须有明确的物理或经济含义,不能是"综合因子"这种黑箱;
  • 因子之间要尽量正交(比如"过路费"和"路线复杂度"可能相关,

这种就要合并或者明确说明);

  • 数量要让整体浮动范围可控——9 个因子 × 10% 幅度 = ±25%,这是业务能接受的上限。

诚实边界: 这个因子体系是我们和业务一起定的第一版, 上线后业务反馈"山区线路的 factor 还是不够准"。 如果重来,我会先上线一版只保留最有把握的 3~4 个因子, 用更保守的范围(±10%),跑半年拿到真实成本数据后, 用历史数据反过来校准每个因子的实际分布,再逐步放开。

Q3:findLowestPath 这个算法有性能问题吗?

A: 有,而且是数量级的问题。-[*..10]-> 会枚举所有 ≤10 跳的路径, 复杂度是 O(D^10)。初期数据量小能跑,但网点上到几万之后一定会炸。

改法(按改造成本): 优先引入 APOC 插件,一行 Cypher 换成 apoc.algo.dijkstraWithDefaultWeightCost; 不想装插件的话,至少把深度从 10 降到 5(业务上超过 5 跳的线路基本不存在), 并且限制中间节点的标签。

追问:你怎么知道 5 跳够用? 这是要从业务数据里验证的,不是拍脑袋。 我当时的做法是:先按 10 跑一遍,把所有实际规划出来的路径长度统计出来, 发现 P99 是 3 跳、最长 5 跳,然后才敢降深度。 同时深度应该做成可配置项,按城市规模分级。

Q4:AI 的引入对系统整体有什么影响?

A: 主要是延迟和可用性。因为 AI 调用是同步 HTTP, 它直接叠加在建线路接口的响应时间里,而 AI 本身不可控。

所以我把它定位成"可降级组件"而不是"核心依赖": Java 先算出 baseCost(一定能算出来),AI 只是在上面乘一个修正系数。 AI 挂掉 → 因子全 1.0 → 成本退化成最朴素的算法,建线路依然成功。

这是一个明确的取舍:我们接受"AI 挂了成本会不准", 但绝不能接受"AI 挂了业务跑不了"。

追问:那你怎么知道 AI 到底带来了多少价值? 诚实的回答是:我们没做严格的 A/B。我们做的是上线后抽样人工复核, 发现大约 80% 的线路 AI 修正幅度在 ±5% 以内。 这说明我们的因子范围设计得比较保守——这既是优点(安全)也是局限(收益不明显)。 要证明价值,应该做的是:用历史真实结算成本做回归, 看加了 AI 因子之后 MAE 降了多少。这个实验我们没做。

Q5:这段代码你会怎么 review ?(主动自曝,请面试官往下问)

A: 我列了几个问题:

  1. System.out.println 打在高德原始响应上,应该改成 @Slf4j 的 log.debug;
  2. findLowestPath 的路径成本在 Cypher 里 sum 了一遍,返回 Java 里又 sum 了一遍,

重复计算;

  1. TransportLineUtils.convert(PathValue) 是 findLowestPath 内联逻辑的重复实现,

完全没有被调用;

  1. import com.itheima.chaincloud.common.exception.CCWebException; 是无用 import;
  2. routeInfoTemplate 在 Java 里写了一份,resources/templates/aigc-cost-factor.txt

又放了一份完全相同的内容,但资源文件没有任何地方加载。

这题是本亮点最好的"自我批评"素材。 最后一条尤其能体现你注意过"配置文件与代码重复"的维护性问题。


七、源码定位速查

文件行作用
transport/entity/node/BaseEntity.java21-47图节点公共属性(含 location Point)
transport/entity/node/{Agency,OLT,TLT}Entity.java16三个 @Node 标签
transport/repository/impl/TransportLineRepositoryImpl.java38-90findShortestPath 跳数最少(shortestPath)
同上92-142findLowestPath 成本最低(全路径枚举)
同上210-231create 双向关系 Cypher
同上261-276分页查询的 Cypher 注入
transport/service/impl/TransportLineServiceImpl.java77-101线路类型 → 节点类型 switch
同上117-197initMap baseCost + AI 因子钳制
同上199-293buildRouteInfo + 文本块 Prompt
同上414-423queryPathByDispatchMethod 调度方式分发
transport/service/impl/CostConfigurationServiceImpl.java24-61Redis Hash 存每公里单价 + 默认值
transport/mq/AuthMQListener.java124-137resolveOrganType 关键字匹配
transport/utils/DistanceCalculator.java—Haversine 直线距离
aigc/service/impl/CostFactorServiceImpl.java1-31Agent 1006 调用(唯一的成本因子 Agent)
aigc/feign/AgentServerClient.java1-26Python AI 服务 HTTP 客户端
aigc/resources/bootstrap.yml15-20Feign 超时(read-timeout 1000s)