ICHIKI
Log

把 AI 编排塞进一个真实产品:TripSmith 复盘(延迟、失败、布局还原)

一个对标携程 AI 行程助手的开源实现。真正难的不是调通大模型,而是三件事:把参考实现量清楚再动手、把载荷从 118KB 压到 9.2KB、以及在网关必然失败的前提下设计失败路径。记录六个决定性取舍与实测数据,包括推理参数为什么必须走 extra_body、轮换指针为什么会跳过模型、开放时间按天摘要为什么会算错。

约 12 分钟 AIAgent架构性能工程实践
关键词:LLM 编排、SSE、payload 工程、模型轮换、降级策略、布局还原

一句话结论

调通大模型是最简单的一步。 真正决定这个项目能不能用的,是三件跟模型无关的事:

  1. 把「参考实现」量清楚,再动手;
  2. 把载荷压到链路能承受的量级;
  3. 在「网关一定会有概率失败」的前提下设计失败路径。

下面按这三点展开,每个结论都带实测数字。

图 1 TripSmith 主界面:左 AI 对话 / 中行程 / 右地图,顶部可选游玩日期,右侧是选中景点的详情大卡
图 1 TripSmith 主界面:左 AI 对话 / 中行程 / 右地图,顶部可选游玩日期,右侧是选中景点的详情大卡

一、复刻:先用 DOM 量,再写代码

目标是对标市面上的 AI 行程助手,尽量贴近它的布局与交互。第一版凭截图目测,被一句话打回:「你这个 UI 布局和参考的都不一样」。

教训很直接:凭截图写,永远对不上。正确做法是把参考页的尺寸测出来:

  • 三栏宽度(460 / 460 / 自适应);
  • 卡片封面 64×64、标题字号、meta 行间距;
  • 划分布局的那个断点。

测完之后布局一次到位。更关键的是——这套测量必须固化成一个可重复执行的检查,否则下次改动又会悄悄跑偏。

于是写了布局守卫脚本:横跨 1600 → 420px 九个宽度,对每个宽度断言三件事:

  1. 每个面板尺寸非零;
  2. 每个面板至少 40% 落在视口内;
  3. 控制台无报错。

它上线第二天就抓到一个真实回归:某个陈旧的 @media (max-width:1280px) 把布局改成纵向,导致行程面板被推到 y=900、地图到 y=1800,用户在 1024~1280px 的笔记本上只能看见会话栏,另外两栏全在屏幕外。

这类 bug 靠肉眼极难发现——因为你自己的显示器往往不在这个宽度区间。

二、延迟:模型选型是第一性能杠杆

最初的诉求是「和携程一样 40 秒左右出内容」。第一版实测 100 秒以上。

逐段埋点后结论很反直觉:代码一行没改,只换模型名,端到端从 100s+ 掉到 20s 量级。

模型类型编排调用耗时行程首次可见全部完成
推理型51~103s47~108s69~158s
轻量非推理3~6s4~6s18~46s

原因是推理型模型把大量时间花在不显示给用户的隐藏推理 token 上。它的输出长度没变,但你为不可见的思考付了双倍时间。

所以「AI 产品慢」的第一嫌疑对象不是 prompt、不是并发,而是模型类型。

推理模型必须传思考参数

换成推理型模型后还有一层坑。同一个 prompt、同一个模型:

是否传思考参数耗时
不传34.0s
reasoning_effort=minimal16.8s
thinking_budget=06.8s
两个都传22.5s
图 2 同一 prompt、同一模型,只改思考参数:「不传」是最慢的一档,不是「不思考」
图 2 同一 prompt、同一模型,只改思考参数:「不传」是最慢的一档,不是「不思考」

两个发现:

  • 「不传」不是「不思考」,而是「用服务端默认的深度思考」——最慢的那档;
  • 把两个开关同时传,反而更慢(22.5s)。所以最终只传 thinking_budget=0。

这里踩了个隐蔽的坑:thinking_budget 不是 OpenAI SDK 声明的参数,直接当关键字传给 create() 会抛 TypeError: unexpected keyword argument。而 TypeError 没有 status_code,会被重试判定逻辑归成「不可重试」——结果三个备用模型在 0.5 秒内全部被判失败。

非标准参数必须走 extra_body(SDK 会原样合并进请求 JSON)。

三、载荷:118KB → 9.2KB

用户报的一个体感问题:「首页点一条行程,等 3.5 秒才跳转,就和卡住一样」。

先分清是「应用慢」还是「链路慢」:

测量点结果
直连服务,详情接口5ms,118540 字节
经隧道,同一接口2.5~40s(首字节 2.49s,其余是传输)
隧道吞吐估算118540B / 7.4s ≈ 16KB/s

应用 5ms 就返回了。那 3.5 秒全是传输。接着拆载荷:

分块字节占比
day_list11158786%
chat 历史1757113.5%
其余~6400.5%

day_list 再往下拆,问题一目了然——全是路线折点:

安仁古镇 → 川西竹海    787 个折点 / 48605 字节
平乐古镇 → 安仁古镇    620 个折点 / 38285 字节
杜甫草堂 → 武侯祠      185 个折点 / 11434 字节

一条 3 天行程 1634 个折点,坐标还带 6 位小数。这是地图服务返回的逐像素级原始轨迹,被原样存库、原样出网。城市尺度下 1 像素约几十米,几百个点里绝大多数对画线毫无贡献。

三层修复

  1. 折点抽稀:Douglas-Peucker(8 米容差)+ 120 点上限 + 坐标 5 位小数,首尾点强制保留(保证路线仍精确连到 POI 标记上)。
  2. 响应压缩:gzip。
  3. 静态资源 Cache-Control:带内容 hash 的资源标 immutable,浏览器不再回源校验;index.html 保持 no-cache。

结果:

原始            118540 B
抽稀后           42710 B   (-64%)
再 gzip           9170 B   (-92% 相对原始)
折点数  1634 → 284
图 3 详情响应体的三层压缩:折点抽稀去掉 64%,再 gzip 累计去掉 92%
图 3 详情响应体的三层压缩:折点抽稀去掉 64%,再 gzip 累计去掉 92%

前端点击耗时:3500ms → 44/48/59ms。

最后一个细节值得单说:压缩不能碰 SSE。流式响应一旦被中间件缓冲,规划过程会变成「几十秒一个字都不出」。所幸主流实现默认把 text/event-stream 排除在外,但这条必须写测试锁死——否则哪天换个中间件就静默炸了。

四、失败:不做本地兜底,反而更快可用

产品决策是明确的:网关失败就把具体错误返回给前端并记录,不要拿本地模板冒充 AI 输出。

这条决策的代价是「用户会看到错误」,好处是你永远不会在不知情的情况下拿到假结果。

但只做到「报错」不够。第一个版本报错信息是这样的:

模型输出解析失败

用户完全不知道发生了什么,只能反复重试。查下去发现根因是一个「顺手」的写法:

raw = await llm.chat_json(prompt)   # 失败时返回空串(旧接口的设计)
data = json.loads(raw)              # 空串 → JSONDecodeError

于是「网关连接失败」被误报成「模型输出解析失败」,而且只发了一条思考节点(界面上是折叠的)、没有发错误事件,前端什么也拿不到。

修法有三层:

  1. 改用返回 (text, error) 的接口,失败时下发 error 事件;
  2. 错误信息逐模型点名,带异常类型、HTTP 状态、上游 body;
  3. JSON 解析失败时,把模型实际吐出来的前 160 字带上:
模型没有按要求返回 JSON,行程未改动。它实际返回的是(前 160 字):'好的,我来帮你调整…'

还有一处更隐蔽的:前端有个兜底

if (!settled) finishAssistant('行程已更新。')

只要后端没发正文,前端就替后端宣布成功。配合上面那个「不发错误事件」,用户看到的就是「一次失败的修改 → 界面显示已更新」。

前端永远不应该替后端编造状态。 这句话值得写进规范。

五、多模型轮换:因为故障是按模型错开的

网关的失败不是均匀的。同一分钟内实测:

模型状态
模型 A正常,2.6s
模型 B连接超时 / 读超时(连不上上游)
模型 C有时连接超时,有时 4.1s 正常

所以在同一个模型上反复重试,很可能只是陪它一起卡。改成每次重试换下一个模型:

主模型超时 → 备用 1 空正文 → 备用 2 成功

线上日志实证:某次编排阶段耗时 64.2s 才成功——正好是两个 30s 超时 + 一次 4s 成功。

三个必须处理的边界

1. 空正文不能当成功。

推理型模型会把 max_tokens 全部烧在思考上,返回空正文 + finish_reason=length。实测出现过 completion_tokens=1400、正文 0 字。这必须判定为失败并换模型,同时把 completion_tokens 报出来——否则你只会看到「模型返回了空内容」这种无法排查的信息。

顺带一个反直觉的结论:max_tokens 要按「链里最费 token 的模型」给。给小了,等于白白牺牲一个备用模型。

2. 不可重试的错误要跳过该模型,而不是整轮失败。

404 / 401 这类错误换模型才有意义,同一模型重试纯属白等。所以要区分「这个模型废了」和「这次请求废了」。

3. 轮换指针不能用取模。

第一版写成 usable[i % len(usable)]。一旦某个模型被标记为坏,usable 变短、下标错位,会把中间那个模型整个跳过去——实测模型 1 失败后直接跳到模型 3,模型 2 从来没被试过。改成「本轮没试过的第一个」。

这三条都有测试,且都来自线上真实故障。

六、开放时间:分号才是分隔符

景点卡片的开放时间来自地图服务,原样显示是这样的(实测 304 字):

1月1日至6月30日,9月1日至12月31日 周二至周日 09:00-17:00开放,最晚进入16:00;
7月1日至8月31日 周二至周日 09:00-20:30开放,最晚进入19:30;周一 全天不开放;
劳动节,端午节,中秋节,国庆节 09:00-20:00开放 最晚进入19:00;每周一不开放

用户在界面上已经选定了出发日期,所以完全可以只显示当天适用的那一段:

日期显示
7 月某个周二09:00-20:30(最晚进入19:30)
7 月某个周一当天闭馆
3 月某个周二09:00-17:00(最晚进入16:00)

原文不丢:卡片上给一个 ⓘ 圆图标,点开看完整长文本。

两个解析陷阱

第一,逗号不能当分隔符。 1月1日至6月30日,9月1日至12月31日 是同一条规则的两个月份区间。按逗号切会把一条规则劈成两半,匹配全错。

第二,一段规则可能同时有「日期区间」和「星期」两个约束,必须两者都命中。

这是写测试时抓出来的:第一版只校验了日期区间,于是「周一 全天不开放」这条规则在周二也命中,把本该显示的 09:00-20:30 显示成了「当天闭馆」。这种错误非常危险——它看起来完全合理,用户不会怀疑,只会以为景点周二不开门。

不知道就说不知道

节假日规则(元旦 / 春节 / 中秋…)需要农历表,我没有。所以只含节假日名的规则在平日不生效,并且:

  • 匹配不到任何规则 → 显示「当天未公示开放时间,建议出行前确认」;
  • 绝不猜测,绝不用第一条规则凑数。

宁可显示一句「建议确认」,也不能给一个看起来精确、实际错误的时间。

七、诚实的缺口

项目里有些地方做不到 1:1,我选择在 README 里逐条写清楚,而不是含糊过去:

  • 参考实现的「日期」tab 是自绘日历,我用原生 date 替代;
  • 类目 POI 的结果集合不可能与参考实现逐条相同(数据源不同);
  • 地图服务不返回评价正文,所以只显示评分与数量,不编造评价;
  • 没有真实「建议玩 N 天」的数据来源,所以不编这个字段。

「缺什么」写清楚,比「看起来什么都有」更有用——前者别人能判断要不要用,后者只会在某天突然坑人。

小结

如果只记三句话:

  1. 先量再写。参考实现的尺寸、断点、间距都能测出来,固化成一个自动化检查,比反复目测省事得多。
  2. 慢先怀疑模型类型,再怀疑代码。同一份代码换模型能差 5 倍;推理模型的隐藏思考是你付钱但用户看不见的时间。
  3. 失败路径要比成功路径设计得更认真。网关一定会失败,问题只是今天还是明天。而失败时最坏的做法是假装成功。

至于「AI 编排」本身——把地理结构先在本地算好、让模型只做「审查 + 起名 + 微调」,反而是最先跑通的部分。真正的工程量都在模型之外。