亮点二 · 图数据库 + LLM:AI 只输出修正系数
业务问题: 业务部门要求"根据实际情况给运输路线算一个更准的成本", 但没人能写清楚"更准"到底要考虑哪些因素——山区高速费高、 绕远路油费高、装卸效率、夜间行驶拥堵……这些经验都在老调度员脑子里。
于是我们做了一个判断:让 LLM 去做"经验判断",但绝不让 LLM 去做"算账"。
这是本项目最值得完整讲的一个案例,因为它同时包含:图数据库选型、 算法权衡、AI 工程化落地、以及一次真实的事故级踩坑。
一、业务场景
1.1 快递网络的物理结构
真实的快递网络是三级的,不是两点一线:
一级转运中心 (OLT) ──干线──▶ 一级转运中心 (OLT) 例:北京 → 上海
│
│ 支线
▼
二级转运中心 (TLT) ──接驳──▶ 网点 (AGENCY) 例:北京朝阳 → 通州
│
│ 接驳
▼
网点 (AGENCY)对应三种线路类型(TransportLineEnum):
| 类型 | 连接 | 业务含义 |
|---|---|---|
干线 TRUNK_LINE | OLT → OLT | 跨城市干线,货车跑长途 |
支线 BRANCH_LINE | TLT → OLT | 区域内的集散 |
接驳 CONNECT_LINE | AGENCY → TLT | 网点到转运中心,最后一公里 |
1.2 为什么这不能建在 MySQL 里
业务要回答的问题有三个层次:
- 单条线路的成本是多少? —— 这可以放关系库
- 从 A 到 B 该走哪条路径? —— 需要多跳搜索
- 如果中途要退回上一级重走,怎么算? —— 需要图上的邻居遍历
在 MySQL 里做多跳搜索,常规做法是递归 CTE:
- 3 跳还能忍,5 跳就慢,10 跳基本不可用;
- 而且每加一跳就要 JOIN 一次,边表被反复扫描;
- 更根本的是:这个数据模型本来就不是"表 + 外键",而是"节点 + 有向边"。
所以选型是:Neo4j 存图,关系不落 MySQL。 这直接催生了 chain-cloud-ms-transport-service 这个没有 MySQL 的微服务。
1.3 业务的核心诉求:线路成本要"更准"
过去成本 = 里程 × 每公里单价。业务提的问题是:
"同样是 1000 公里,走京藏高速和走京昆高速成本能一样吗? 山区国道油耗高,高速费高,夜间跑长途效率低—— 我们能不能让系统自动把这些因素考虑进去?"
这就是 AI 介入的位置。而介入的方式,就是这个案例的技术核心。
二、方案设计
2.1 图模型
三类节点:
// 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):
@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(); // 类型两类有向关系(成对创建):
// 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,可热改):
// DispatchConfigurationServiceImpl
public DispatchConfigurationDTO findConfiguration() {
String dispatchTime = opsForValue().get("DISPATCH_CONFIGURATION:TIME"); // 默认 2 小时
String dispatchMethod = opsForValue().get("DISPATCH_CONFIGURATION:METHOD"); // 默认 2 = 成本最低
...
}分发逻辑:
// 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
// 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. 成本最低 —— 全路径枚举 + 排序
// 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 先算出确定的基准成本
// 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 里,默认值:
// 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:
请根据以下新建路线信息输出路线 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 侧做钳制,再乘上去
// 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-197chain-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 个维度基本能对应上调度员的经验分类。
并且用两层钳制把风险关死:
单因子 ∈ [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), 需要根据消息内容决定写入哪种节点。而消息里没有机构类型字段。
现有实现的判断方式:
// 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
}
}这个设计非常脆弱:
- 机构名是运维随手填的,改个字就挂了;
- 已经有 TODO,说明作者自己也发现了;
- 返回
null后switch (organTypeEnum)直接 NPE。
README 里写的方案是"用 AI 判断机构类型"(Agent 1005), 但 Java 侧目前只字未提,Agent 1005 也没有实现。
如果重做: 优先级应该是
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"的方案信息利用率高得多。
还留了一条审计线索:
transportLine.setExtra(costFactorResponse.getReason());把 LLM 说的理由存进 extra。业务如果觉得某条线的成本算得不对, 可以查这条记录看 AI 当时是怎么说的。
⚠️ 但这里有个真实 bug:
create的 Cypher 里extra:''是硬编码的空串, 而且只写cost/number/name/distance/time/startOrganId/endOrganId, 根本没 bindextra。所以这段理由在"新建线路"时存不进去, 只有走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 的绕路因子计算直接用了这两个值:
double straightLineKm = DistanceCalculator.calculateDistance(
startOrgan.getLongitude(), startOrgan.getLatitude(),
endOrgan.getLongitude(), endOrgan.getLatitude());如果经纬度反了,算出来的"直线距离"就是错的, 喂给 LLM 的 straightLineDistanceKm 也就是错的, 进而导致 detour 因子判断失准。
这是个典型的"文档、写入、读取三方约定不一致"的经典事故。 面试时讲这个的价值在于:它证明你会去追一个数字为什么不对, 而不是"测试没报错就算对"。
修复方案: 统一约定为 x = 经度、y = 纬度(与 GeoJSON、PostGIS、 MongoDB 的 $geoIntersects 一致),改写入端,并给 TransportLineRepository 增加一个一次性坐标校验的启动检查:
// 启动时抽样校验:经度必须在 [-180,180],纬度必须在 [-90,90]
// 北京的经度 ~116.3,如果被当纬度存就是 116.3(>90),一眼看出问题坑 2:Cypher 字符串拼接 = 注入风险
事实 1(安全): 路径查询用的是参数绑定,是安全的。
neo4jClient.query(cypherQuery)
.bind(start.getBid()).to("startId")
.bind(end.getBid()).to("endId")事实 2(不安全): 分页查询用了字符串拼接。
// 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 侧已保证不是字符串:
// OrganRepositoryImpl#findByBid
var cypherQuery = StrUtil.format("""
MATCH (n)
WHERE n.bid = {}
RETURN n""", bid);这里 name 甚至只做了去引号处理(StrUtil.removeAll(name, '\'', '"')), 没有参数化也没有转义。
修复:统一改用参数绑定。
neo4jClient.query("MATCH ()-[r]->() WHERE r.name CONTAINS $name RETURN r")
.bind(name).to("name")坑 3:删除线路只删了一半
事实:
一条逻辑线路会创建 2 条 关系(IN_LINE + OUT_LINE), 但 id(r) 只有一个:
// create 里 RETURN count(r) AS c —— count 的是 IN_LINE,返回 1删除时:
MATCH ()-[r]->() WHERE id(r) = $lineId
DELETE r只删了 IN_LINE,OUT_LINE 留在了图里。
代码里 Controller 的注解其实承认了这一点:
@ApiOperation(value = "删除路线", notes = "删除路线,单向删除")为什么这不是一个纯技术 Bug: 图里的边是双向创建的,因为路径查询用有向模式。 删掉一条之后,这个方向的路径就规划不出来了, 但反向查询仍然"正常"——所以这个 Bug 会以 "有时候能查到路线,有时候查不到"的形式在生产上表现出来,极难定位。
修复:
// 一次删两个方向
MATCH (a)-[r:IN_LINE|OUT_LINE]-(b)
WHERE id(r) = $lineId OR {startOrganId: a.bid, endOrganId: b.bid} = ...更好的方案是不要给关系做主键,用业务唯一键 (startOrganId, endOrganId, type) 做双向删除和更新。
坑 4:专线和临时线路会 NPE
事实:
// 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); // ← NPETransportLineEnum 定义了 5 种线路,但 switch 只处理了 3 种。 专线(任务城市到任意城市)和临时线路落到 default: break;, firstNode / secondNode 保持 null,紧接着就 NPE。
根因分析: 这反映了一个领域建模的断层—— 专线和临时线路在概念上不是"边": 专线是"为某次特定任务临时开的通道", 临时线路是"运营临时加的线"。 它们没有固定的起点终点组合,本来就不该塞进 IN_LINE/OUT_LINE 这对关系模型。 正确建模是给线路加一个生效时间窗(effectiveFrom / effectiveTo), 让同一对节点可以有"常态边"和"临时边"并存。
坑 5:成本改了,历史线路不重算
CostConfigurationServiceImpl#saveConfiguration 用的是 putAll:
// 只 putAll,不 delete
stringRedisTemplate.opsForHash().putAll(CC_TRANSPORT_COST_REDIS_KEY, costMap);两个后果:
- 删不掉的旧数据 —— 运营下线了一个线路类型,Hash 里的旧 key 还在;
- 历史线路成本永远是旧值 —— 改了每公里单价,
Neo4j 里已有线路的 cost 不会更新。
第二条的业务影响很实在:运营调完单价,路径规划的排序完全不变, 业务会问"我明明改了配置怎么没效果"。
修复思路(两条路):
| 方案 | 做法 | 适用 |
|---|---|---|
| 成本快照 | 线路的 cost 一旦生成就固化,另存一个 costConfigVersion | 财务口径要对账时用这个(推荐) |
| 动态成本 | 不存 cost,路径搜索时按当前配置实时算 | 配置变动不频繁时可接受,但路径搜索会变慢 |
金融/财务口径必须用第一条——历史成本不能因为配置变了就变。
五、如果重做,我会怎么改
| 优先级 | 改动 | 收益 |
|---|---|---|
| P0 | 统一经纬度 x/y 约定为 x=经度,加启动期校验 | 消除一类静默的数值错误 |
| P0 | 全量 Cypher 改参数绑定 | 消除注入风险 |
| P0 | resolveOrganType 兜底返回 UNKNOWN 而非 null,补齐专线/临时线路分支 | 消除 NPE |
| P1 | findLowestPath 换成 APOC dijkstra 或深度降为 5 | 消除路径数爆炸 |
| P1 | 给 aigcFeign.identifyCostFactor 加 fallbackFactory 返回全 1.0 因子 | AI 挂了不影响建线路(见下) |
| P1 | 线路删除改双向;关系不用 id(r) 做主键 | 消除"方向性丢图" |
| P2 | 用 confidence 做阈值:低于 0.6 时只用 baseCost 并标记"待复核" | 让置信度真正参与决策 |
| P2 | 专线/临时线路引入时间窗,而不是塞进固定边 | 领域模型正交 |
P1 里那条最值得展开讲
现状: AI 调用没有任何容错。
CostFactorResponse costFactorResponse = aigcFeign.identifyCostInfo(routeInfo);
// 没有 try-catch,没有 fallbackFactory,没有熔断而超时配置是:
# 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 分钟,然后超时失败。
修复(三层,从便宜到贵):
// 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);
}// 3. Feign 层超时只能兜底,真正的解法是把超时前置
// 在 Java 侧用 CompletableFuture.orTimeout(3, TimeUnit.SECONDS)
// 或用 Resilience4j 的 TimeLimiter 包一层feign:
client:
config:
default:
connect-timeout: 3000
read-timeout: 5000
chain-cloud-agent-server: # 按服务名单独覆盖
connect-timeout: 1000
read-timeout: 3000降级实现(一行):
@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: 三个原因,按重要性排:
- 可验证:因子有明确的合法区间,我可以在 Java 侧钳制。
直接返回一个数,我没有任何手段判断它是 1.23 还是 123000。
- 可组合:9 个因子各自独立,我可以在 Java 侧做统计、
做校准(比如用历史真实成本做线性回归修正整体偏置), 也能一眼看出是哪个维度在推高成本。
- 可降级: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: 我列了几个问题:
System.out.println打在高德原始响应上,应该改成@Slf4j的log.debug;findLowestPath的路径成本在 Cypher 里sum了一遍,返回 Java 里又sum了一遍,
重复计算;
TransportLineUtils.convert(PathValue)是findLowestPath内联逻辑的重复实现,
完全没有被调用;
import com.itheima.chaincloud.common.exception.CCWebException;是无用 import;routeInfoTemplate在 Java 里写了一份,resources/templates/aigc-cost-factor.txt
又放了一份完全相同的内容,但资源文件没有任何地方加载。
这题是本亮点最好的"自我批评"素材。 最后一条尤其能体现你注意过"配置文件与代码重复"的维护性问题。
七、源码定位速查
| 文件 | 行 | 作用 |
|---|---|---|
transport/entity/node/BaseEntity.java | 21-47 | 图节点公共属性(含 location Point) |
transport/entity/node/{Agency,OLT,TLT}Entity.java | 16 | 三个 @Node 标签 |
transport/repository/impl/TransportLineRepositoryImpl.java | 38-90 | findShortestPath 跳数最少(shortestPath) |
| 同上 | 92-142 | findLowestPath 成本最低(全路径枚举) |
| 同上 | 210-231 | create 双向关系 Cypher |
| 同上 | 261-276 | 分页查询的 Cypher 注入 |
transport/service/impl/TransportLineServiceImpl.java | 77-101 | 线路类型 → 节点类型 switch |
| 同上 | 117-197 | initMap baseCost + AI 因子钳制 |
| 同上 | 199-293 | buildRouteInfo + 文本块 Prompt |
| 同上 | 414-423 | queryPathByDispatchMethod 调度方式分发 |
transport/service/impl/CostConfigurationServiceImpl.java | 24-61 | Redis Hash 存每公里单价 + 默认值 |
transport/mq/AuthMQListener.java | 124-137 | resolveOrganType 关键字匹配 |
transport/utils/DistanceCalculator.java | — | Haversine 直线距离 |
aigc/service/impl/CostFactorServiceImpl.java | 1-31 | Agent 1006 调用(唯一的成本因子 Agent) |
aigc/feign/AgentServerClient.java | 1-26 | Python AI 服务 HTTP 客户端 |
aigc/resources/bootstrap.yml | 15-20 | Feign 超时(read-timeout 1000s) |