出行助手全场景流程图(含代码锚点 · 无 MCP 版)
日期: 2026-08-16 | 依据: ADR-039 · 北极星与终局蓝图 用途: 联调 / 验收 / 排障时对照「说一句话 → 走哪段代码 → 谁调的 LLM、谁查的 SQL、那段文字谁写的」 讲「一句话走进哪条链路」,和 北极星与终局蓝图 分工:蓝图管去哪,本文管怎么走。
0. 先分清三件事(这是理解本助手的关键)
一次问答里有三个角色,各干各的、边界很硬:
| 角色 | 干什么 | 谁来做 | 看得见的产物 |
|---|---|---|---|
| 理解(NLU) | 把「想去赛里木湖,7天,大概2000预算」翻译成结构化槽位 | 大模型 DeepSeek(ILlmPort) | 一段 intent JSON(内部,不直接给用户) |
| 查活动 | 拿槽位查平台真实活动 | Java 现有查询(searchActivitiesAdvanced)→ SQL | 活动卡片列表(真数据) |
| 写文案 | 拼「已按…找到 N 个」+「小贴士」 | Java 模板 + 本地知识库 RAG | 气泡里的中文 |
一句话:模型只负责听懂,不查库、不编活动、不写你看到的那段中文。查库是 Java 干的,文案也是 Java 拼的。
为什么没有 MCP 也能查 SQL? 因为我们用的是 「结构化输出 + Java 派发」,不是「模型自己调工具」:模型吐 JSON → Java 读 JSON → Java 调用平台已有的活动查询服务 → 该服务照常走 SQL。模型全程碰不到数据库(见 §6 三种范式对比)。
1. 目前做到哪(M1 验收口径)
| 能力 | 状态 | 说明 |
|---|---|---|
| 一句话 → 结构化意图(NLU) | ✅ | DeepSeek deepseek-chat,response_format=json_object |
| 意图 → 现有查询 → 真活动卡片 | ✅ | 复用 searchActivitiesAdvanced,不新建搜索仓储 |
| 卡片深链进详情 | ✅ | /pages/activity/detail?id= |
| 越界拒答 / 求推荐 / 0 条放宽 | ✅ | OUT_OF_SCOPE / RECOMMEND / EMPTY 三分流 |
| 查天气(多意图 M3a-①) | ✅ | QUERY_WEATHER → get_weather(IWeatherPort.getDailyForecast 7 天 + grounding,2026-08-18) |
| 做行程/攻略(生成型 M3a-②) | ✅ | MAKE_TRAVEL_PLAN → make_travel_plan(查真实活动+天气 → 独立 IItineraryGenerationPort 生成每日行程;卡片只出 Java 真相、查不到活动不生成;plan.enabled 默认关,ADR-041,2026-08-18) |
| 多轮补槽(先条件后目的地) | ✅ | Redis 会话 TTL 30min,失败降级单轮 |
| 命中补「小贴士」(RAG) | ✅ | 本地 destinations.json 关键词检索 |
| 观测/治理(token/耗时/审计/日限额) | ✅ | [AssistantAudit] 结构化日志 + Redis 日限额 |
| Mock 无 Key 可跑 + 真实模型评测集 | ✅ | provider=mock 兜底;ASSISTANT_EVAL_LIVE 记分卡 |
| 真 Function Calling(M3b · 模型自选工具) | ✅ | 手写 tool-calling 双通道:agent.enabled=true 走 Agent 循环(search/weather/get_activity_detail),默认关;见 §6.5 + ADR-042,2026-08-18 |
| 检索升「匹配」(M2 · 千人千面) | ✅ 最小闭环 | 候选后置人口硬过滤 + 画像重排:登录且画像非空即生效,画像空/未登录优雅退回;见 §13 + ADR-043,2026-08-19 |
| MCP / 向量 RAG | ❌ | 刻意不做(见 §6、§9),后续里程碑(M7 / M4a)再演进 |
结论:截图里「说话→卡片→小贴士」闭环已成立,且确认真走了 DeepSeek(证据见 §8)。
2. 总览:一句话是怎么变成卡片的
只有一处调模型(图中 ★ 的
extractSearchIntent)。往后的查库、拼文案、补贴士,全是确定性 Java 代码。
3. 场景 A · 命中(截图第 2 张:赛里木湖 / 2000)
验收:说「想去赛里木湖,7天,大概2000预算」→ 4 张真卡片 + 小贴士;点卡片进详情。
模型这一轮的全部产出(开 DEBUG 就能看到,见 §8),示意:
{
"intent": "SEARCH_ACTIVITIES",
"destinationKeywords": ["赛里木湖"],
"province": null, "city": null,
"dateStart": null, "dateEnd": null,
"maxPrice": 2000,
"needClarification": false, "clarifyQuestion": null
}截图文案只有「赛里木湖 / ≤2000元」没有日期 → 说明模型这次没把「7天」锁成日期窗(date 留空),于是用了默认 30 天窗,命中 4 条 ≤2000。这不影响结果,属正常。
| 节点 | 代码 |
|---|---|
| 端点 | tour-mate-platform-trigger/.../assistant/AssistantController.java |
| 编排 | AssistantApplicationService.chat |
| 调模型 | ILlmPort.extractSearchIntent → OpenAiCompatibleLlmAdapter |
| 意图→条件 | AssistantApplicationService.toCriteria |
| 查活动(真相) | IActivityQueryApplicationService.searchActivitiesAdvanced |
| 文案模板 | buildHitReply / describeConditions |
| 小贴士 RAG | appendKnowledge → KeywordKnowledgeRetrievalAdapter + assistant-knowledge/destinations.json |
4. 场景 B · 多轮补槽(先给条件,再给目的地)
验收:先说「本周末,预算2000」(记下 sessionId)→ 助手追问;再带同一 sessionId 说「赛里木湖」→ 直接出卡片。
- 会话状态在 Java 侧 Redis(
AssistantSession),不是把历史丢回模型做多轮——省 token、可控。 - 载入失败自动降级为单轮(
loadSessioncatch)。
| 节点 | 代码 |
|---|---|
| 会话模型 | domain/.../assistant/model/AssistantSession.java |
| 会话读写 | IAssistantSessionPort → RedisAssistantSessionRepository |
| 合并逻辑 | AssistantApplicationService.mergeInto / toEffectiveIntent |
5. 场景 C · 越界 / 求推荐 / 0 条
- 越界拦截在模型 + Java 双保险:模型判
OUT_OF_SCOPE,即使漏判,工具也只有「查活动」,不会执行写操作。 - 截图第 1 张「有什么好玩的推荐吗」→ 命中 RECOMMEND → 走热门推荐,就是这条路。
| 节点 | 代码 |
|---|---|
| 拒答 | AssistantApplicationService.refuse |
| 推荐 | AssistantApplicationService.recommend → getHomeRecommendedActivities |
| 0 条放宽 | AssistantApplicationService.buildEmptyReply |
6. 三种「给模型用工具」的范式(我们选了第 1 种)
| ①结构化输出 + Java 派发(本项目) | ②真 Function Calling | ③MCP | |
|---|---|---|---|
| 谁决定调哪个工具 | Java 代码(按 intent 分流) | 模型(吐 tool_call) | 模型(跨进程/跨应用) |
| 模型看得到数据库吗 | 看不到 | 看不到(我们执行后回传) | 看不到 |
| 调 LLM 次数 | 1 次(只抽意图) | ≥2 次(决定→回填→总结) | ≥2 次 |
| 成本/时延 | 最低 | 中 | 中高 |
| 可控性/安全 | 最高(工具白名单写死) | 中(模型可能乱调) | 中 |
| 适合场景 | 一句话→一次查询(正是 C 端筛活动) | 需要模型自由编排多工具 | 给 Cursor/Claude 等外部客户端复用工具 |
为什么第 1 种够用:C 端「一句话筛活动」本质就是一次 NLU + 一次查询,不需要模型来回决策。第 1 种最省钱、最可控、最不会「编造活动」。
什么时候升级到 ②/③(写进里程碑,不是现在):
- 出现「帮我规划3天行程,顺便查天气和门票」这类需要模型自己串多个工具的需求 → 升 ②。
- 要把这些工具开放给外部 AI 客户端(Cursor / Claude Desktop / B 端)复用 → 上 ③ MCP。
演进成本很低:ILlmPort 边界不变,届时新增 SpringAiLlmAdapter / Function Calling 版本即可,业务编排基本复用。
6.5 M3b Agent 通道(真 Function-Calling · 双通道,2026-08-18 已落地)
范式②「真 Function Calling」已经做出来了(ADR-042),但默认关、与范式①双通道并存。深入讲解见
助手-Java编排vs模型编排 §10。
6.5.1 入口分叉:一个开关决定走哪条腿
- 关(默认)= 完全等于现在的行为(§2 那张图);开 = 整条 chat 交给模型编排,
understood=null、meta.toolCalled=agent。 - 卡片 grounding 两条腿都不变:活动只来自 Java 查询,模型碰不到 id。
6.5.2 Agent 循环(ReAct)时序:一次「赛里木湖有什么活动」
max-iterations封顶循环轮数(防模型无限调工具);触顶用已收集素材收尾。tool_call_id把「模型说要调哪个」和「这是那次的结果」缝在一起(协议要点)。
6.5.3 协议 → 代码锚点(tools / tool_calls / tool role 在哪)
| 环节 | 代码 |
|---|---|
声明工具 tools | OpenAiCompatibleLlmAdapter.buildChatRequestBody / toJsonTool ← AssistantApplicationService.agentTools() |
收 tool_calls | OpenAiCompatibleLlmAdapter.parseChatResult(arguments JSON→Map) |
| 回写 assistant.tool_calls | agentChat → LlmMessage.assistantToolCalls → toJsonMessage(assistant) |
| 执行工具(Java) | AssistantApplicationService.executeTool → execSearch/execWeather/execDetail |
回喂 tool role | LlmMessage.toolResult → toJsonMessage(tool) |
| 循环 | agentChat 的 for(iter<max-iterations) |
6.5.4 什么时候走哪条(速记)
能枚举 + 要合规 + 图便宜 → Java 编排(默认);开放式 + 多步 + 要灵活 → 模型编排。 决策矩阵见 Java 编排与 Function Calling §10。高危写操作(报名/支付/组局)永远走 Java 固定流程 + 幂等/Saga——这也是 M3b 收尾要补的「工具风险分级 + HITL」。
7. 截图那段中文,逐句是谁写的
以截图第 2 张的回复为例:
已按「赛里木湖 / ≤2000元」为你找到 4 个活动: ← ①Java 模板
(4 张活动卡片) ← ②SQL 查出来的真数据
小贴士 · 赛里木湖:6-9月是最佳季节,7月环湖野花… ← ③本地知识库 RAG| 片段 | 来源 | 模型参与? |
|---|---|---|
| ①「已按…找到 4 个活动」 | buildHitReply + describeConditions(读 intent 的 keywords/maxPrice + hitCount) | ❌ 纯 Java |
| ②活动卡片(标题/价格/日期/封面) | searchActivitiesAdvanced → DB | ❌ 纯 SQL |
| ③「小贴士 · 赛里木湖…」 | appendKnowledge → destinations.json | ❌ 本地知识库 |
| (看不见的)intent JSON | DeepSeek | ✅ 模型只产出这个 |
所以你看到的中文没有一个字是模型生成的散文。模型只把「想去赛里木湖,7天,2000」变成了机器能查的槽位。这正是「AI 应用」和「套壳聊天」的分水岭——把模型当可靠的翻译器,而非不可控的作文机。
8. 怎么确认「真走了大模型」+ 看原始返回
证据三件套(你截图第 4 张日志已具备):
- Bean 是真实适配器:
provider=openai-compatible才创建OpenAiCompatibleLlmAdapter;mock走MockLlmAdapter。 - 审计带真 token:
[AssistantAudit] {"model":"deepseek-chat","promptTokens":379,"completionTokens":68,"latencyMs":1858...}。- Mock 恒为
"model":"mock"、token=0(LlmUsage.none)。379/68 这种数字只能来自真实 API 的usage字段——这是最硬的铁证。
- Mock 恒为
- 看模型原始 JSON(本次已加):
OpenAiCompatibleLlmAdapter现在会打:[Llm] → 请求 … body=…(我们发出去的,不含 Key)[Llm] ← 原始返回 1858ms: {"id":...,"choices":[{"message":{"content":"{...}"}}],"usage":{...}}- dev profile 默认
com.alisunxin.api.infrastructure: DEBUG(application-dev.yml),重编译重启即可看到;prod 设LOG_LEVEL_INFRASTRUCTURE=DEBUG才打。
走的是什么模型:deepseek-chat(DeepSeek 通用对话档,对应 V3 系,非 deepseek-reasoner 推理档)——正是我们抽意图想要的便宜快档。
为什么 DeepSeek 官网用量页是 ¥0 / 0 tokens(截图第 3 张):
- 若你用的是中转 key(
base-url不是api.deepseek.com,如apis.itedus.cn/v1):请求消耗的是中转商的 DeepSeek 额度,你个人 DeepSeek 官网永远显示 0,要去中转平台后台看用量。 - 若是直连:官网页面有「~5 分钟延迟」,刷新等一会即可。
- 一句话诊断:
echo $ASSISTANT_LLM_BASE_URL——不是api.deepseek.com就是走中转。无论哪种,§8 的三件套已证明确实调了真实模型。
9. 换模型(deepseek-chat → V4 / 别家)要不要重做这套?
基本不用,因为隔离做在 ILlmPort:
| 换什么 | 要动的地方 | 工作量 |
|---|---|---|
| 同兼容协议换档(chat→V4-flash/pro、换 qwen/gpt-4o) | 改 ASSISTANT_LLM_MODEL 环境变量 | 0 代码 |
| 换中转/直连 | 改 ASSISTANT_LLM_BASE_URL + Key | 0 代码 |
| V4 推理档想关思考省 token | ASSISTANT_LLM_THINKING=disabled(见 ADR-039 §D2) | 0 代码 |
| 换到非 OpenAI 兼容协议(如某私有 SDK) | 新增一个 XxxLlmAdapter implements ILlmPort | 1 个适配器类 |
| 升级到 Spring AI / Function Calling | 新增 SpringAiLlmAdapter,编排复用 | 1 类 + 少量编排 |
关键:换模型是换「翻译器」,不是重写「业务」。查活动、拼文案、多轮、审计这些都在模型之外,不受影响。这就是把
spring-ai/厂商 SDK 锁在 Infrastructure、Domain 只认ILlmPort的价值。
10. 排障速查
| 现象 | 先查 |
|---|---|
| 怀疑没走真模型 | 审计日志 model 是不是 deepseek-chat、token 是否 >0;是 mock 说明 provider 没切 |
看不到 [Llm] ←原始返回 | 是否 dev profile / infrastructure=DEBUG;是否重编译重启;provider 是否 openai-compatible |
| DeepSeek 官网用量 0 | base-url 是否中转;直连则等 5 分钟刷新 |
| 报 503 模型不可用 | Key/base-url 是否配全;中转是否欠费/限流;[Llm] 模型调用失败 日志 |
| 命中 0 条 | 是否执行造数 055_seed_assistant_demo_activities.sql;预算/时间是否过窄 |
| 文案没有小贴士 | 关键词是否命中 destinations.json;knowledgePort 是否装配 |
| 返回 404 | tourmate.assistant.enabled 是否 true |
11. 代码锚点汇总
| 层 | 类 / 文件 |
|---|---|
| Trigger | tour-mate-platform-trigger/.../assistant/AssistantController.java |
| 编排(Domain) | tour-mate-platform-domain/.../assistant/service/AssistantApplicationService.java(chat 意图派发 + agentChat M3b 循环) |
| 出站 Port | domain/.../assistant/port/out/{ILlmPort,IAssistantSessionPort,IAssistantAuditPort,IAssistantRateLimitPort,IKnowledgeRetrievalPort}.java(ILlmPort.chat = M3b 工具对话) |
| M3b 工具契约(Domain) | domain/.../assistant/model/{LlmMessage,ToolSpec,ToolCall,LlmChatResult}.java |
| 真实模型适配器 | infrastructure/.../adapter/assistant/llm/OpenAiCompatibleLlmAdapter.java(chat 手写 tool-calling) |
| Mock 适配器 | infrastructure/.../adapter/assistant/llm/MockLlmAdapter.java(chat 确定性 tool_call) |
| 模型参数 | infrastructure/.../adapter/assistant/llm/AssistantLlmProperties.java |
| 会话/审计/限额 | infrastructure/.../adapter/assistant/{session,governance}/*.java |
| 知识库 RAG | infrastructure/.../adapter/assistant/knowledge/KeywordKnowledgeRetrievalAdapter.java + resources/assistant-knowledge/destinations.json |
| 活动真相(复用) | IActivityQueryApplicationService.searchActivitiesAdvanced |
| M2 匹配(画像重排+硬过滤) | domain/.../activity/service/ActivityMatchingService.java(口 IActivityMatchingService)· ActivitySpecifications.age/genderMatches · ActivityCategoryCodeMapper |
| M2 活动双轨打标 | domain/.../activity/service/ActivitySystemTagsBackfillApplicationService.java · 057_add_activities_system_tags.sql |
| M2 排序评测轨 | infrastructure/.../adapter/matching/ActivityMatchingEvalTest.java |
| 对外契约 | tour-mate-platform-api/.../dto/assistant/{AssistantChatRequest,AssistantChatResponse,ActivityCardDTO}.java |
| 验收造数 | docs/migrations/055_seed_assistant_demo_activities.sql · 056_seed_matching_demo_activities.sql · 058_seed_demo_user_interest_profile.sql(演示画像) |
12. 常见疑问(FAQ)
Q1「拒答」和「查不到」是一回事吗? 不是。拒答只在意图越界时触发(写游记/退款/订票等做不了的事),话术写死在 refuse()。问一个偏僻/没听过的地方(如「日照金山」)不会被拒答——它是 SEARCH 意图,正常查库,库里没有就走 buildEmptyReply() 的放宽建议(提预算/放宽时间/换关键词)。一句话:拒答=做不了;查不到=能做但没货。
Q2 什么时候才会追加「小贴士」(走知识库)?只有 SEARCH_ACTIVITIES + 有目的地 + 命中 ≥1 条 时才追加(见 chat() 里 hitCount > 0 ? appendKnowledge(...))。推荐/拒答/追问/0 条这几条路都不补。命中后用目的地关键词去 destinations.json 按词重叠打分取 top1,命不中就什么都不加。
Q3 我一直不给目的地会怎样? 只要合并后仍没有目的地,就不查库,每轮回一句追问(不出卡片),直到你给出目的地、或改说「随便推荐」(走 RECOMMEND)、或问越界的事(走拒答)。会话在 Redis 存 30min,读失败降级为单轮。
Q4 我这套「查内部数据库」算 RAG 吗?和 RAG 什么区别?不算。关键看「检索到的东西要不要回喂给模型再生成」:
- RAG = 检索到知识文本 → 塞回模型上下文 → 模型据此生成自然语言答案("生成"是核心)。
- 我们的活动查询 = 模型只在入口把话翻译成槽位,之后 Java 拿槽位查库,结果直接当卡片展示,从不回喂给模型。所以它是 Tool Use / 结构化查询,不是 RAG。
- 更进一步:我们目前连"生成"这步都没有——回复文案是 Java 模板拼的。连那句「小贴士」也只是把
destinations.json检索结果直接字符串拼接,没让模型改写,所以它是"退化版 RAG(只检索不生成)"。 - 一句话:RAG 在"模型之前/之中"补料让模型生成;我们的查询在"模型之后"取真相直接展示。位置和用途都不同。
Q5 AssistantApplicationService 是"我在编排大模型 / 教大模型怎么执行"吗?编排该谁做? 要分成两处看,别混:
- "教模型"发生在 System Prompt(
OpenAiCompatibleLlmAdapter.systemPrompt())——你用自然语言告诉模型:你的任务是抽意图、输出这样的 JSON、遵守这些规则。这才是"教/约束模型"。 AssistantApplicationService编排的是"业务流程",不是"编排模型"——它是你写死的 Java 控制流(限流→调一次模型→按 intent 分流→查库→合并→拼文案→审计),模型只是这条流水线里的一个环节(理解),它看不到也管不到这段代码。- "编排"到底谁做?两种范式:
- 代码编排(我们现在):下一步做什么由 Java 的 if/else 写死。适合"一句话→一次查询"这种单步/固定流程。可控、便宜、可测。
- 模型自主编排(Agent,未来):你给模型一堆工具+目标,模型自己决定"先查A→看结果→再查B→最后总结"(ReAct/planning)。适合"帮我规划3天行程并订票"这种开放式多步。灵活但贵、慢、需要防乱调。
- 结论:现在编排是你写代码做的(模型不编排、只理解);等业务出现真正的多步开放任务,才把编排权逐步交给模型——这正是北极星蓝图里从"固定工作流"走向"Agent"的那几级台阶。
13. M2:检索怎么升成「匹配」(千人千面)
ADR-043。一句话:查库(谁能看)不变,多加一层"这批候选里,哪些更适合你、按你口味重排"。
13.1 加在哪(在"查到候选"之后、"拼文案"之前)
13.2 三样地基缺一不可(都在本轮补齐)
| 缺口 | 补法 | 代码 |
|---|---|---|
| 活动没有和画像对齐的类目 | 加 activities.system_tags(英文 code 双轨),发布/编辑自动打标 + 回补端点 | 057_add_activities_system_tags.sql · ActivitySystemTagsBackfillApplicationService · ActivityCategoryCodeMapper |
| 活动人口约束没进领域模型 | minAge/maxAge/genderRestriction 贯通 PO↔Aggregate + 填 Spec 空壳 | ActivityConverter · ActivitySpecifications.age/genderMatches |
| 助手不读画像 | 后置 personalize() 消费 top_categories | ActivityMatchingService.score() · UserInterestProfileAggregate.scoreOfCategory |
13.3 效果对比(同一批候选,换个人看)
| 场景 | M1(升级前) | M2(升级后) |
|---|---|---|
| travel=100 的用户搜"周末活动" | 按创建时间倒序,徒步/美食混排 | 徒步/骑行(system_tags 命中 travel)被顶到前面 |
| 40 岁用户 | 能看到"18-25 青年专场" | 该活动被硬过滤剔除 |
| 男用户 | 能看到"女生专场" | 被硬过滤剔除 |
| 未登录 / 新用户画像空 | —— | 与 M1 完全一致(优雅退回,不劣化) |
13.4 为什么不给活动也建"多路召回"
帖子侧有 4 路召回 + MMR 打散(ADR-020),但活动量小(几十条),扩召回面收益接近 0。所以走 ADR-033 的取向:结构化筛 + 轻量重排就够体现"千人千面",不背多路召回的复杂度。
13.5 排序质量怎么测(比意图更"灵魂"的地方)
意图能二值判定(对/错),排序不能——它看"更合适的是否更靠前"。所以另开一条评测轨 ActivityMatchingEvalTest:固定画像 + 固定候选 → 断言顺序/剔除。首批 5 例:画像重排 / 年龄过滤 / 性别过滤 / 空画像保序 / 未登录原样。
本地体验:新库画像为空看不到重排 → 跑 058_seed_demo_user_interest_profile.sql(把 @demo_user 改成你的 userId),换不同 top_categories 再搜,顺序会随人设变。
13.6 待补的洞(Phase B)
评分质量分(活动 rating/review_count 进聚合)· 地理近 / 临近开始加权 · "为什么推给你"解释 · Agent(M3b)通道也接个性化。