Skip to content

出行助手全场景流程图(含代码锚点 · 无 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),示意:

json
{
  "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
小贴士 RAGappendKnowledge → KeywordKnowledgeRetrievalAdapter + assistant-knowledge/destinations.json

4. 场景 B · 多轮补槽(先给条件,再给目的地) ​

验收:先说「本周末,预算2000」(记下 sessionId)→ 助手追问;再带同一 sessionId 说「赛里木湖」→ 直接出卡片。

  • 会话状态在 Java 侧 Redis(AssistantSession),不是把历史丢回模型做多轮——省 token、可控。
  • 载入失败自动降级为单轮(loadSession catch)。
节点代码
会话模型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 在哪) ​

环节代码
声明工具 toolsOpenAiCompatibleLlmAdapter.buildChatRequestBody / toJsonTool ← AssistantApplicationService.agentTools()
收 tool_callsOpenAiCompatibleLlmAdapter.parseChatResult(arguments JSON→Map)
回写 assistant.tool_callsagentChat → LlmMessage.assistantToolCalls → toJsonMessage(assistant)
执行工具(Java)AssistantApplicationService.executeTool → execSearch/execWeather/execDetail
回喂 tool roleLlmMessage.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 JSONDeepSeek✅ 模型只产出这个

所以你看到的中文没有一个字是模型生成的散文。模型只把「想去赛里木湖,7天,2000」变成了机器能查的槽位。这正是「AI 应用」和「套壳聊天」的分水岭——把模型当可靠的翻译器,而非不可控的作文机。


8. 怎么确认「真走了大模型」+ 看原始返回 ​

证据三件套(你截图第 4 张日志已具备):

  1. Bean 是真实适配器:provider=openai-compatible 才创建 OpenAiCompatibleLlmAdapter;mock 走 MockLlmAdapter。
  2. 审计带真 token:[AssistantAudit] {"model":"deepseek-chat","promptTokens":379,"completionTokens":68,"latencyMs":1858...}。
    • Mock 恒为 "model":"mock"、token=0(LlmUsage.none)。379/68 这种数字只能来自真实 API 的 usage 字段——这是最硬的铁证。
  3. 看模型原始 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 + Key0 代码
V4 推理档想关思考省 tokenASSISTANT_LLM_THINKING=disabled(见 ADR-039 §D2)0 代码
换到非 OpenAI 兼容协议(如某私有 SDK)新增一个 XxxLlmAdapter implements ILlmPort1 个适配器类
升级到 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 官网用量 0base-url 是否中转;直连则等 5 分钟刷新
报 503 模型不可用Key/base-url 是否配全;中转是否欠费/限流;[Llm] 模型调用失败 日志
命中 0 条是否执行造数 055_seed_assistant_demo_activities.sql;预算/时间是否过窄
文案没有小贴士关键词是否命中 destinations.json;knowledgePort 是否装配
返回 404tourmate.assistant.enabled 是否 true

11. 代码锚点汇总 ​

层类 / 文件
Triggertour-mate-platform-trigger/.../assistant/AssistantController.java
编排(Domain)tour-mate-platform-domain/.../assistant/service/AssistantApplicationService.java(chat 意图派发 + agentChat M3b 循环)
出站 Portdomain/.../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
知识库 RAGinfrastructure/.../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_categoriesActivityMatchingService.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)通道也接个性化。

Powered by VitePress