TripPlanner AI——智能旅行行程生成器:从约束优化到 LLM 的协同落地

部署运行你感兴趣的模型镜像

一句话导读:本文用“背景–原理–实践–总结”主线,带你从工程视角把“行程生成”拆成偏好建模 + 候选检索 + 约束优化 + LLM 语义编排 + 交互修正的闭环系统,并提供可直接运行/改造的多段代码与前端编辑器示例。


目录

  1. 为什么要做 TripPlanner AI

  2. 系统总体架构与数据流
    2.1 核心模块与职责
    2.2 关键数据结构
    2.3 运行时链路

  3. 核心原理:约束优化与 LLM 的分工
    3.1 偏好建模:把“感觉”变成“参数”
    3.2 候选检索:POI 收集与打分
    3.3 约束优化:时间窗、交通与体力预算
    3.4 LLM 编排:把可行解讲成人话、再修正
    3.5 评价指标与离线 AB

  4. 从 0 到 1 实战:后端 Orchestrator、调度器与前端编辑器
    4.1 项目骨架与依赖
    4.2 代码示例一:FastAPI Orchestrator
    4.3 代码示例二:OR-Tools 调度器
    4.4 代码示例三:Vue3 行程编辑器
    4.5 代码示例四:结构化提示词与 JSON 校验

  5. 进阶能力:多人偏好冲突、跨城多日、预算敏感度

  6. 工程化与可运维:缓存、观测、灰度与安全

  7. 方案对比与选型建议

  8. 常见坑与调优清单

  9. 总结:把“行程”做成“产品”


为什么要做 TripPlanner AI

旅行行程规划是一个“看似轻松、实则复杂”的组合优化问题。用户只说“我 3 天去东京,想拍照+咖啡+轻松”,但背后隐藏着开放时间限制、交通时空距离、预算分配、体力与节奏偏好等大量隐性约束。单靠关键字检索或者粗糙模板,很难输出“既合理又好玩”的行程。

传统做法要么手工做功课(费时费力,信息噪声巨大),要么使用规则模板(千篇一律,个性化失败)。近两年 LLM 带来“自然语言理解与生成”的跃迁,看上去可以“一步到位”。但纯 LLM 常会忽略时间窗、跨区交通时长、预约号源等刚性约束,导致“文案漂亮但跑不通”。

TripPlanner AI 的目标不是靠 LLM“编故事”,而是把LLM 的语义理解/表述求解器的可行性/最优性结合起来:LLM 负责把人的需求变成机器可解的约束与偏好,再把求解器产出的结果翻译成人能理解/编辑的行程。这才是落地之道。


系统总体架构与数据流

核心模块与职责

  • Preference Parser(LLM):把用户自然语言(“我不想太累”“更喜欢博物馆而不是购物”)解析为参数化偏好(打分权重、时间窗软硬约束、节奏阈值)。

  • POI Retriever:调用地图/场馆/社区数据源,得到候选 POI 列表(经纬度、开放时间、受欢迎度、票价、停留建议时长等)。

  • Routing & Scheduling Solver(OR-Tools/CP-SAT/VRP-TW):在时间与空间维度上寻找可行且尽量优的序列,满足硬约束(开放时间、闭馆日、交通可达性)并优化软目标(喜好满足度、换乘次数等)。

  • LLM Realizer:把可行日程翻译成叙述化、可改写的“人话行程”,并对“不合理处”进行协同修正(Repair)。

  • Front-end Editor:提供可视化拖拽与约束反馈,用户修改后再次回流求解形成闭环。

关键数据结构

  • UserProfile:城市/日期/预算/餐饮禁忌/节奏偏好(紧凑、松弛)。

  • POI{id, name, lat, lng, tags[], open_hours[], price, dwell_time_range, score}

  • ConstraintSet:硬约束(时间窗/必须-禁止/预约时段)、软约束(偏好权重、步行上限)、交通模型(步行/地铁/打车)。

  • Itinerary:多日列表,每日是有序的 Visit 序列(含到达/离开时刻、交通方式、总时长、解释说明)。

运行时链路

  1. 用户输入自然语言 + 旅行基本信息。

  2. LLM 解析偏好 → 形成参数化 ConstraintSet

  3. 检索多个数据源汇总 POI → 初筛打分。

  4. 调用求解器计算可行序列 → 得到初版行程。

  5. LLM 叙述化 + 解释 + 可修正建议。

  6. 前端可视化编辑 → 回流二次求解(保持约束一致)。


核心原理:约束优化与 LLM 的分工

偏好建模:把“感觉”变成“参数”

很多失败的行程生成,问题出在“感觉”没落到“参数”。“不要太累”到底是每日步行不超过 12km?还是每两站之间步行 < 1.2km?“喜欢小众咖啡”如何在 POI 标签与口碑上落地成加权?

可行的做法是把偏好拆成可加总的项:步行惩罚、跨区惩罚、热门度溢价、题材匹配度、餐馆评分、午晚餐时间靠近 12:00/19:00 的惩罚等。每项对应一个权重,权重来源可以是默认模板 + LLM 解析 + 交互学习三者融合。

与“只让 LLM 自己拍脑袋排序”相比,参数化的偏好是可验证、可迁移、可调参的。即便更换数据源或换城市,也能稳定工作。

候选检索:POI 收集与打分

数据源通常包括开源与商业两类:OpenStreetMap/Nominatim、OSRM/地图路线、社区榜单与平台 API 等。检索阶段先取宽口径候选,基于地理边界与主题标签过滤,再用评分模型进行第一轮粗排,例如 score = w_popularity*pop + w_theme*theme + w_rating*rating - w_price*price

与“先排序再贪心挑选”不同,我们只需要一个稳定且可解释的初筛分数,不追求一次排序定江山。真正的序列与时间分配交给求解器与后续修正环节完成。

约束优化:时间窗、交通与体力预算

行程本质接近 VRP with Time Windows(带时间窗的车辆路径问题)。哪怕只有一位旅行者,也可视作单车辆、多站点、带停留时长的 TSP-TW。
硬约束如:开放时段、闭馆日、预约时段、城市跨区交通时间、午晚餐必须在某时间窗口;软约束如:步行距离上限、换乘次数、热门度/题材匹配度
求解器(如 OR-Tools CP-SAT)可以在可行性层面给出保证,再在目标函数上逐步逼近用户喜好。

与“LLM 直接输出序列”相比,求解器更擅长保障无冲突可执行。LLM 的强项放在“解析约束”和“描述与修正”,而不是“保证可行”。

LLM 编排:把可行解讲成人话、再修正

当求解器给出“可行但生硬”的列表,LLM 可以做两件事:
1)叙述化:把“9:10-10:20 上野公园(步行 1.1km)→ 10:40-12:00 国立博物馆(地铁 12 分钟)”写成读得懂的行程与注意事项;
2)修正建议:检测“午餐时间偏晚/过度跨区/某景点用户强偏好但未入选”等,并给出替代建议。然后在不破坏硬约束的前提下,小范围改序或替换。

这比“纯求解器”更具可用性,因为真实世界的“好玩”并不等同单一目标函数,而是可解释、可沟通、可二次调整的。

评价指标与离线 AB

离线评估建议拆成三层:

  • 可行性指标:违反时间窗/闭馆/冲突比例为 0;

  • 效率指标:总步行、跨区次数、换乘时长、停留时长与建议值的偏差;

  • 满意度代理:主题覆盖度、热门与小众平衡、用餐时间合理性。

可用历史行程数据人工标注做对照,离线 AB 调整权重/惩罚项,把“好不好用”转化为参数可微调的系统


从 0 到 1 实战:后端 Orchestrator、调度器与前端编辑器

项目骨架与依赖

  • 后端:Python 3.10+、FastAPI、Pydantic、httpx、OR-Tools、uvicorn、Redis(缓存)

  • 前端:Vue 3 + TypeScript + Vite(或 Nuxt)、MapLibre GL、Tailwind(可选)

  • 数据:OSM/Nominatim、OSRM 或外部路线 API、社区榜单/平台 API

参考文档:

代码示例一:FastAPI Orchestrator

功能:串联偏好解析(可对接任意 LLM)→ 候选检索 → 调度求解 → LLM 叙述化 → 返回 JSON/ICS。示例中 llm_parse_preferences 与 llm_realize_itinerary 用占位实现,方便你替换任意模型/供应商。

# file: server/tripplanner_server.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field, validator
from typing import List, Optional, Dict, Any, Tuple
import httpx
import asyncio
import json
import time
import uuid

# ---- Data Models ----
class UserProfile(BaseModel):
    city: str
    start_date: str  # YYYY-MM-DD
    days: int
    budget: Optional[float] = None
    pace: str = Field("relaxed", description="relaxed|balanced|tight")
    dietary: Optional[List[str]] = None
    notes: Optional[str] = None

class POI(BaseModel):
    id: str
    name: str
    lat: float
    lng: float
    tags: List[str] = []
    open_hours: Dict[str, List[Tuple[str, str]]] = {}  # {"Mon":[("09:00","17:00"),...]}
    rating: Optional[float] = None
    price: Optional[float] = None
    dwell_min: int = 45
    dwell_max: int = 120
    popularity: Optional[float] = None

class ConstraintSet(BaseModel):
    must: List[str] = []
    forbid: List[str] = []
    lunch_window: Tuple[str, str] = ("11:30", "13:30")
    dinner_window: Tuple[str, str] = ("18:00", "20:00")
    max_walk_km_per_day: float = 12.0
    prefer_tags: Dict[str, float] = {}   # {"museum":1.2, "coffee":1.1}
    avoid_transfers: bool = True
    hard_time_windows: Dict[str, List[Tuple[str,str]]] = {}  # poi_id -> [(start,end)]

class ItineraryVisit(BaseModel):
    poi_id: str
    arrive: str
    depart: str
    transport: str
    note: Optional[str] = None

class DayPlan(BaseModel):
    date: str
    visits: List[ItineraryVisit] = []
    metrics: Dict[str, Any] = {}

class Itinerary(BaseModel):
    city: str
    days: List[DayPlan]
    score: float = 0.0
    warnings: List[str] = []

class PlanRequest(BaseModel):
    user: UserProfile
    query: str = Field(..., description="Natural language preferences")

class PlanResponse(BaseModel):
    plan: Itinerary
    narrative: str
    version: str = "1.0.0"
    debug: Optional[Dict[str, Any]] = None

# ---- LLM Placeholders (replace with real LLM calls) ----
async def llm_parse_preferences(query: str) -> ConstraintSet:
    """
    Turn free-form text into parameterized constraints.
    Replace with your LLM provider + structured output.
    """
    # naive parsing as placeholder
    prefer = {}
    if "博物馆" in query or "museum" in query.lower():
        prefer["museum"] = 1.2
    if "咖啡" in query or "coffee" in query.lower():
        prefer["coffee"] = 1.1
    pace = "relaxed" if "轻松" in query else "balanced"
    return ConstraintSet(prefer_tags=prefer, lunch_window=("11:30","13:30"), dinner_window=("18:00","20:00"))

async def llm_realize_itinerary(plan: Itinerary) -> str:
    """
    Turn machine plan into human-friendly narrative with tips.
    Replace with your LLM provider.
    """
    parts = [f"目的地:{plan.city},共 {len(plan.days)} 天。"]
    for d in plan.days:
        parts.append(f"【{d.date}】共 {len(d.visits)} 站;建议注意体力与补水。")
        for i, v in enumerate(d.visits, 1):
            parts.append(f"{i}. {v.arrive}-{v.depart} {v.transport} → {v.poi_id}")
    return "\n".join(parts)

# ---- Retriever (OSM/Nominatim/Your DB) ----
async def fetch_poi_candidates(city: str, tags: List[str]) -> List[POI]:
    # In production, call Nominatim/Places and your own DB, then merge & dedup.
    # Here we return mock data.
    base = [
        POI(id="ueno-park", name="上野公园", lat=35.715, lng=139.773, tags=["park"], open_hours={"Mon":[("05:00","23:00")]}, popularity=0.9, dwell_min=50),
        POI(id="tnm", name="东京国立博物馆", lat=35.718, lng=139.776, tags=["museum"], open_hours={"Mon":[("09:30","17:00")]}, popularity=0.95),
        POI(id="blue-bottle", name="Blue Bottle 清澄白河", lat=35.673, lng=139.799, tags=["coffee"], open_hours={"Mon":[("08:00","19:00")]}, popularity=0.85),
        POI(id="asakusa", name="浅草寺", lat=35.714, lng=139.796, tags=["temple"], open_hours={"Mon":[("06:00","17:00")]}, popularity=0.98),
    ]
    return base

# ---- Distance/Time (use OSRM or Directions API in prod) ----
async def travel_minutes(a: POI, b: POI, mode: str="transit") -> int:
    # placeholder: rough euclidean to minutes
    dist_km = ((a.lat-b.lat)**2 + (a.lng-b.lng)**2) ** 0.5 * 100
    base = 10 if mode == "transit" else 20
    return max(8, int(base + dist_km*5))

# ---- Solver Stub (delegate to OR-Tools) ----
async def solve_itinerary(user: UserProfile, cons: ConstraintSet, pois: List[POI]) -> Itinerary:
    # For brevity, call a real scheduler in another module (see next code block).
    # Here we just return a simple feasible sequence per day.
    days = []
    for d in range(user.days):
        visits = []
        day_pois = pois[:min(3, len(pois))]
        cur = "09:00"
        last = None
        for p in day_pois:
            if last:
                t = await travel_minutes(last, p)
                # add transfer buffer
            arrive = cur
            depart = "10:30" if arrive == "09:00" else "12:00"
            visits.append(ItineraryVisit(poi_id=p.name, arrive=arrive, depart=depart, transport="metro"))
            cur = "14:00"
            last = p
        days.append(DayPlan(date=f"{user.start_date}", visits=visits, metrics={"walk_km": 7.2}))
    return Itinerary(city=user.city, days=days, score=0.82, warnings=[])

# ---- FastAPI ----
app = FastAPI(title="TripPlanner AI")

@app.post("/plan", response_model=PlanResponse)
async def plan(req: PlanRequest):
    try:
        cons = await llm_parse_preferences(req.query)
        # tags union from preferences + defaults
        tags = list(cons.prefer_tags.keys()) or ["museum", "park", "coffee"]
        pois = await fetch_poi_candidates(req.user.city, tags)
        plan = await solve_itinerary(req.user, cons, pois)
        narrative = await llm_realize_itinerary(plan)
        return PlanResponse(plan=plan, narrative=narrative, debug={"poi_count": len(pois), "pref": cons.dict()})
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

# Run: uvicorn server.tripplanner_server:app --reload

代码示例二:OR-Tools 调度器

功能:把时间窗 + 停留时长 + 转场时长 + 午/晚餐窗口转成约束,求解单旅行者的多站点行程。可扩展到多人(多车辆)与跨城(分日/分簇)。

# file: solver/scheduler.py
from typing import List, Dict, Tuple, Callable
from dataclasses import dataclass
from ortools.sat.python import cp_model

@dataclass
class VisitNode:
    id: str
    dwell_min: int
    dwell_max: int
    open_start: int  # minutes from day start
    open_end: int

def minutes(hhmm: str) -> int:
    h, m = hhmm.split(":")
    return int(h) * 60 + int(m)

def hhmm(m: int) -> str:
    return f"{m//60:02d}:{m%60:02d}"

def make_nodes(pois: List[Dict]) -> List[VisitNode]:
    res = []
    for p in pois:
        hours = p.get("open_hours", {"Mon":[("09:00","17:00")]})["Mon"][0]
        res.append(VisitNode(
            id=p["id"],
            dwell_min=p.get("dwell_min", 45),
            dwell_max=p.get("dwell_max", 120),
            open_start=minutes(hours[0]),
            open_end=minutes(hours[1]),
        ))
    return res

def solve_day(nodes: List[VisitNode],
              travel: Callable[[str, str], int],
              lunch_window: Tuple[str, str] = ("11:30","13:30"),
              start_time="09:00", end_time="21:00") -> Dict:
    model = cp_model.CpModel()
    n = len(nodes)
    START = minutes(start_time)
    END = minutes(end_time)
    LUNCH_S, LUNCH_E = minutes(lunch_window[0]), minutes(lunch_window[1])

    # Variables
    start = [model.NewIntVar(START, END, f"s_{i}") for i in range(n)]
    end   = [model.NewIntVar(START, END, f"e_{i}") for i in range(n)]
    used  = [model.NewBoolVar(f"u_{i}") for i in range(n)]
    order = {(i,j): model.NewBoolVar(f"o_{i}_{j}") for i in range(n) for j in range(n) if i!=j}

    # Dwell & open window
    for i, node in enumerate(nodes):
        model.Add(end[i] - start[i] >= node.dwell_min).OnlyEnforceIf(used[i])
        model.Add(end[i] - start[i] <= node.dwell_max).OnlyEnforceIf(used[i])
        model.Add(start[i] >= node.open_start).OnlyEnforceIf(used[i])
        model.Add(end[i]   <= node.open_end).OnlyEnforceIf(used[i])

    # Order and travel time
    BIG = 10**5
    for i in range(n):
        for j in range(n):
            if i == j: continue
            t = travel(nodes[i].id, nodes[j].id)
            # if i before j
            model.Add(start[j] >= end[i] + t - BIG*(1-order[(i,j)]))
            # break cycles
            model.Add(order[(i,j)] + order.get((j,i), model.NewBoolVar("tmp")) <= 1)

    # Lunch soft-constraint: at least one visit intersects lunch window
    lunch_hits = []
    for i in range(n):
        li = model.NewBoolVar(f"lunch_{i}")
        model.Add(start[i] <= LUNCH_E).OnlyEnforceIf(li)
        model.Add(end[i]   >= LUNCH_S).OnlyEnforceIf(li)
        lunch_hits.append(li)
    model.Add(sum(lunch_hits) >= 1)

    # Objective: maximize used nodes and minimize end time + travel penalties
    # For demo, approximate travel penalty via pair order count
    obj_terms = []
    obj_terms.append(sum(used) * 1000)
    obj_terms.append(-sum(end))  # earlier finish preferred
    model.Maximize(sum(obj_terms))

    solver = cp_model.CpSolver()
    solver.parameters.max_time_in_seconds = 5.0
    res = solver.Solve(model)

    solution = {"status": str(res)}
    seq = []
    if res in (cp_model.OPTIMAL, cp_model.FEASIBLE):
        for i in range(n):
            if solver.Value(used[i]) or True:  # demo: include all
                seq.append({
                    "id": nodes[i].id,
                    "arrive": hhmm(solver.Value(start[i])),
                    "depart": hhmm(solver.Value(end[i])),
                })
        solution["visits"] = sorted(seq, key=lambda x: x["arrive"])
    return solution

# 使用示例(生产中由 orchestrator 调用):
# nodes = make_nodes([{"id":"ueno","dwell_min":60,"open_hours":{"Mon":[("05:00","23:00")]}}])
# def fake_travel(a,b): return 12
# day = solve_day(nodes, fake_travel)

代码示例三:Vue3 行程编辑器

功能:展示每日时间轴 + 可拖拽顺序 + 右侧地图,每次修改触发“约束检查与二次求解”。示例用最少依赖,便于粘贴改造。

<!-- file: web/src/components/TripPlanner.vue -->
<template>
  <div class="w-full grid grid-cols-12 gap-4 p-4">
    <div class="col-span-4 border rounded-2xl p-3">
      <h2 class="text-xl font-bold mb-2">行程天数</h2>
      <div v-for="(day, idx) in plan.days" :key="idx" class="mb-4">
        <h3 class="font-semibold">第 {{ idx+1 }} 天 · {{ day.date }}</h3>
        <ul>
          <li v-for="(v, i) in day.visits" :key="i" class="flex items-center gap-2 py-1">
            <span class="text-xs text-gray-500">{{ v.arrive }}~{{ v.depart }}</span>
            <input class="border px-1 py-0.5 rounded"
                   v-model="day.visits[i].poi_id" />
            <button class="text-sm underline" @click="removeVisit(idx, i)">删除</button>
            <button class="text-sm underline" @click="move(idx, i, -1)">上移</button>
            <button class="text-sm underline" @click="move(idx, i, 1)">下移</button>
          </li>
        </ul>
        <button class="mt-2 px-2 py-1 bg-black text-white rounded"
                @click="addVisit(idx)">添加站点</button>
      </div>
      <button class="px-3 py-2 bg-blue-600 text-white rounded"
              @click="reSolve">重新求解</button>
    </div>

    <div class="col-span-8 border rounded-2xl p-3">
      <h2 class="text-xl font-bold mb-2">地图与约束反馈</h2>
      <div id="map" class="w-full h-96 border rounded mb-3"></div>
      <div class="text-sm text-gray-700 whitespace-pre-wrap">
        <strong>提示:</strong>{{ narrative }}
      </div>
      <div v-if="warnings.length" class="mt-2 text-red-600">
        <div v-for="(w,i) in warnings" :key="i">⚠ {{ w }}</div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
type Visit = { poi_id:string; arrive:string; depart:string; transport:string }
type DayPlan = { date:string; visits:Visit[]; metrics:Record<string,any> }
type Itinerary = { city:string; days:DayPlan[]; score:number; warnings:string[] }

const plan = reactive<Itinerary>({
  city: '东京',
  days: [{ date:'2025-10-01', visits:[
    { poi_id:'上野公园', arrive:'09:00', depart:'10:20', transport:'walk' },
    { poi_id:'东京国立博物馆', arrive:'10:45', depart:'12:00', transport:'metro' },
  ], metrics:{} }], score:0.8, warnings:[]
})

const narrative = ref('加载中…')
const warnings = ref<string[]>([])

function move(d: number, i: number, step: number) {
  const v = plan.days[d].visits
  const j = i + step
  if (j < 0 || j >= v.length) return
  const [cur] = v.splice(i,1)
  v.splice(j,0,cur)
}
function removeVisit(d: number, i: number) {
  plan.days[d].visits.splice(i,1)
}
function addVisit(d: number) {
  plan.days[d].visits.push({ poi_id:'新地点', arrive:'14:00', depart:'15:00', transport:'walk' })
}

async function reSolve() {
  const payload = {
    user: { city: plan.city, start_date: plan.days[0].date, days: plan.days.length },
    query: '轻松一些,喜欢博物馆和咖啡',
  }
  const res = await fetch('/plan', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload) })
  const data = await res.json()
  narrative.value = data.narrative
  // 简化:直接覆盖
  Object.assign(plan, data.plan)
  warnings.value = data.plan.warnings || []
}

onMounted(() => {
  narrative.value = '编辑后点击“重新求解”以获得更合理安排。'
  // 此处可初始化 MapLibre GL,并在 reSolve 后更新 Marker/Line
})
</script>

<style scoped>
#map { background: #f7f7f7; }
</style>

代码示例四:结构化提示词与 JSON 校验

功能:强约束输出结构,让 LLM 产出严格 JSON,保证与后端 Pydantic/Schema 对齐。生产中推荐加严格 JSON schema 校验、字段纠错与自动重试

# file: ai/structured_output.py
from pydantic import BaseModel, Field, ValidationError
from typing import List, Dict, Tuple, Optional
import json

class ParsedPreference(BaseModel):
    must: List[str] = []
    forbid: List[str] = []
    lunch_window: Tuple[str, str] = ("11:30","13:30")
    dinner_window: Tuple[str, str] = ("18:00","20:00")
    max_walk_km_per_day: float = 12.0
    prefer_tags: Dict[str, float] = {}

PREF_SYSTEM_PROMPT = """你是旅行偏好解析器。把用户关于行程的自然语言需求,转换为严格 JSON:
- 字段:must[], forbid[], lunch_window[], dinner_window[], max_walk_km_per_day, prefer_tags{}
- 时间格式:HH:MM
- prefer_tags value ∈ [0.5, 1.5],1.0为中性偏好
- 回复只输出 JSON,不要注释或多余文本
"""

def build_user_prompt(city: str, days: int, text: str) -> str:
    return f"""目的地:{city},天数:{days}
用户描述:{text}
请综合生成严格 JSON。
"""

def call_llm_stub(system: str, user: str) -> str:
    # 用你熟悉的 LLM 客户端替换此处
    # 这里返回一个可解析示例
    return json.dumps({
        "must": [],
        "forbid": [],
        "lunch_window": ["11:30","13:30"],
        "dinner_window": ["18:00","20:00"],
        "max_walk_km_per_day": 10.5,
        "prefer_tags": {"museum":1.25,"coffee":1.1}
    }, ensure_ascii=False)

def parse_preferences(city: str, days: int, text: str) -> ParsedPreference:
    sys = PREF_SYSTEM_PROMPT
    usr = build_user_prompt(city, days, text)
    for _ in range(3):
        raw = call_llm_stub(sys, usr)
        try:
            data = json.loads(raw)
            pref = ParsedPreference(**data)
            # 约束后处理:阈值裁剪
            pref.max_walk_km_per_day = max(4.0, min(20.0, pref.max_walk_km_per_day))
            for k, v in list(pref.prefer_tags.items()):
                pref.prefer_tags[k] = float(max(0.5, min(1.5, v)))
            return pref
        except (json.JSONDecodeError, ValidationError):
            # 自动重试,或者降级到默认模板
            continue
    # 兜底
    return ParsedPreference(prefer_tags={"general":1.0})

进阶能力:多人偏好冲突、跨城多日、预算敏感度

多人旅行经常出现“偏好冲突”:A 想打卡美术馆,B 想排球鞋购物,C 想多喝咖啡。解决思路不是取平均,而是引入Group Utility:对加入/排除某 POI 的总满意度变化做边际计分,必要时引入分组活动(比如下午拆分两组)。求解器上就是多车辆/多路线 + 汇合点的变体。

跨城多日行程通常带来“时间簇”:例如第 1 天东京,第 2 天箱根,第 3 天回东京。我们可先做城市级别的分簇(各自用本地路网与 POI),然后在全局层面安排城际换乘时段。求解器分两层:宏观(城际) + 微观(城市内),各自有时间窗与最优目标。

预算敏感度可做成参数扫掠(Sensitivity Analysis):在“票价/餐饮/交通”三个维度分别给权重,离线跑不同组合,产出一组 Pareto 前沿方案,前端让用户在“省钱↔省时↔省力”之间拖动滑块即时切换行程。

sequenceDiagram
  participant U as User
  participant F as Frontend
  participant O as Orchestrator
  participant S as Solver
  participant L as LLM

  U->>F: 调整权重滑块
  F->>O: 发送新参数
  O->>S: 约束优化
  S-->>O: 可行解/指标
  O->>L: 生成叙述与提示
  L-->>O: 可读行程
  O-->>F: 更新视图与对比面板

工程化与可运维:缓存、观测、灰度与安全

工程实战里,“快而稳”比“极致最优”更重要。缓存层建议把“POI 详情 + 路网时长矩阵(起点×终点×时间)”分级缓存;对热门城市甚至做预计算。同时,新增“输入哈希计划结果”的计划缓存,用户微调参数时优先做增量求解。

观测性要到位:暴露每次求解的约束数量、可行性失败类型、求解时长、违规修正次数等指标到 Prometheus/Grafana。遇到“闭馆冲突高发”的城市场景,优先排查数据源,避免把脏数据丢给 LLM 擦屁股。

灰度与安全层面,第三方 API(地图/路线/票务)要降级策略:不可用时回退到简化模型并显著标注“精度下降”。LLM 调用必须限流与审计,防止提示注入(如诱导输出 API Key)。用户数据(出行日期/位置)属于敏感信息,遵循最小化存储与到期清理策略。


方案对比与选型建议

数据源

  • OpenStreetMap + Nominatim:免费、可自建,覆盖广但细粒度结构化属性有限;适合自建与成本可控。

  • 商业 Places(Google/等):质量高、口碑/开放时间更全;成本与配额受限。

  • 混合策略:POI 初筛走 OSM + 社区榜单,热门景点/开放时间核验走商业 API。

路径引擎

  • OSRM:开源、响应快,适合自托管;公交/步行细节需要额外配置。

  • 商业 Directions:精度好、模式全;成本较高。

  • 实践中常见做法是区域热度 + 高速缓存矩阵混合,减少实时线路查询。

优化器

  • OR-Tools CP-SAT:可玩性强、可行性保障好,适合复杂约束;学习曲线略高。

  • MIP/ILP:建模清晰但复杂约束下性能敏感;

  • 启发式(遗传/贪心/禁忌搜索):实现快,全球最优没保证但足够好 + 可解释也很香。

  • 推荐组合:CP-SAT 打可行 + 启发式做微调,再交给 LLM 做叙述与修正建议。


常见坑与调优清单

  1. 时间窗碎片化:某些景点中午闭馆/周二休息,别用“平均开放时间”糊弄,必须日期粒度

  2. 预约场馆冲突:票务预约是硬约束,先占位再排其余站点。

  3. 跨区代价低估:只看直线距离很容易翻车,务必加入行政/地理分区跃迁惩罚

  4. LLM 幻觉:坚持先求解可行 → 再让 LLM 解释;不要让 LLM 直接生成“看起来很美”的不可行表。

  5. 评价闭环缺失:收集用户实际完成率、放鸽子率、修改点,回灌到偏好权重与推荐池。

  6. 性能:城市热门×候选 POI 过大时,先做粗筛(主题/距离/口碑)、再分簇、再局部求解,别一口吞全城。


总结:把“行程”做成“产品”

TripPlanner AI 的关键,不是“一个更聪明的大模型”,而是把问题分层

  • 让 LLM 专注“把人话转成参数/把参数讲成人话”;

  • 让求解器专注“把不可行变可行/把可行变更优”;

  • 让前端把“可视化修改”落实为回流求解

当你把偏好参数化、把约束结构化、把数据可验证化,“行程生成”就从“创意文案”变成了“可运营的产品”。下一步可以尝试:

  • 加入多人偏好冲突求解与分组策略

  • 预算/省时/省力三轴的Pareto 前沿

  • 支持ICS 导出票务/预约联动

  • 在线 A/B持续优化权重。

您可能感兴趣的与本文相关的镜像

Llama Factory

Llama Factory

模型微调
LLama-Factory

LLaMA Factory 是一个简单易用且高效的大型语言模型(Large Language Model)训练与微调平台。通过 LLaMA Factory,可以在无需编写任何代码的前提下,在本地完成上百种预训练模型的微调

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值