Skip to content

助手 · Java 编排 vs 模型编排与 Function Calling ​

日期: 2026-08-18(M3b 落地后补 §10)| 作者: sunxin(+ Cursor AI 结对讲解) 关联代码: AssistantApplicationService.chat / .agentChat(M3b)· ILlmPort.chat · OpenAiCompatibleLlmAdapter.chat · IActivityQueryApplicationService · IWeatherPort 关联专题: 北极星与终局蓝图 · 出行助手全场景流程图 · ADR-042 姊妹篇: 助手-意图识别是怎么做到的 · 助手-大模型幻觉与grounding

TL;DR ​

  • 「编排」= 决定「先干嘛、后干嘛、调哪个工具、调几次」的那段控制流。 谁来做这个决定,分成两个流派:
    • Java 编排(代码编排):下一步做什么由你写的 if/else 决定。可控、便宜、可测。这是本项目现在的做法(M3a 固定工作流)。
    • 模型编排(Function Calling / Agent):下一步做什么由模型自己决定(吐 tool_call、可多轮循环 ReAct)。灵活、能处理开放式多步任务,但更贵、更慢、需防乱调。M3b 已于 2026-08-18 落地(手写 tool-calling 双通道,默认关,ADR-042)——代码地图 + 「什么时候走哪条」见 §10。
  • 两种编排里,「执行工具的都是你的 Java」(调你已有的 ApplicationService)。区别只在**「谁决定调哪个工具」。所以「交给模型」交的是决策权**,不是执行权——执行代码你照样要写。
  • Java 编排没被淘汰:tourmate.assistant.agent.enabled 默认 false=走 Java 编排(稳/省/可评测),true=走模型编排。二者双通道并存、按场景取舍,不是「上了新的就删旧的」。口诀:能枚举 + 要合规 + 图便宜 → Java;开放式 + 多步 + 要灵活 → 模型。
  • Q4 的答案(已做出来了):升级到真 Function Calling,要写:① ILlmPort 扩一个「带工具的对话」方法(chat);② 工具的 JSON schema(agentTools());③ 一个「收到 tool_call → 执行 → 回填 → 再问模型」的调度循环(agentChat);④ 工具风险分级 + 高危 HITL(尚缺,见 §10.4)。
  • 换模型会不会重做?基本不用重做业务:工具定义 + 工具执行 + 调度循环是你的资产,都在模型之外。换模型 = 换 ILlmPort 的 Adapter 实现 + 跑评测回归。唯一风险是「新模型不支持 function calling」,那就退回结构化输出派发——所以 ILlmPort 要设计成两种范式都能装。

目录 ​


1. 场景:一个「制定出行计划」引出的问题 ​

用户说:「帮我定个去赛里木湖的出行计划」。这一句要做好几件事:

  1. 查赛里木湖未来几天天气(能不能去)
  2. 查平台里赛里木湖相关活动(有啥可报名)
  3. 取赛里木湖攻略(最佳季节、注意事项)
  4. 把上面这些真实信息喂给模型,生成一份每日计划

问题来了:「先查天气还是先查活动?查完天气要不要根据天气再决定查哪些活动?这个决定谁来做?」 —— 这就是「编排」。

  • 现在(M3a):你用 Java 写死顺序(查天气 → 查活动 → 取攻略 → 生成)。
  • 将来(M3b):把这个顺序的决定权交给模型,模型自己看着办(先查天气,发现下雨了,自己决定改查室内活动……)。

2. 什么是「编排」 ​

编排(Orchestration)= 多步任务里「决定步骤顺序 + 选哪个工具 + 传什么参 + 要不要再来一次」的控制逻辑。

一次问答里的角色分工(本项目):

角色干什么谁做
理解(NLU)把话翻译成分诊单模型(ILlmPort)
编排决定按什么顺序调哪些工具⭐ 本文主角
执行真去查活动 / 查天气 / 取攻略Java(调 ApplicationService / Port)
表达拼文案 / 生成计划Java 模板 或 模型生成

「编排」和「理解」是两件事:理解是「听懂这一句」,编排是「听懂之后这一趟怎么走」。本项目现在「理解」交给模型,「编排」握在 Java 手里。


3. Java 编排 vs 模型编排 ​

维度Java 编排(代码编排,现在)模型编排(Function Calling / Agent,未来)
谁决定下一步你写的 if/else模型(吐 tool_call)
模型吐什么一个 intent 分诊单tool_call{工具名, 参数},可连续多个
调 LLM 次数1 次(只抽意图)≥2 次(决定→执行回填→再决定→…→总结)
成本 / 时延最低中~高(多轮累加)
可控 / 安全最高(工具白名单写死)中(模型可能乱调、要治理)
适合场景一句话→固定流程(找活动、查天气)开放式多步(规划+查天气+比价+订票)
状态在哪你手里(Redis 会话)滚在对话上下文里(更贵)
对应范式①结构化输出 + Java 派发②Function Calling / ③MCP
对应里程碑M3aM3b / M7

核心一句:Java 编排 = 「模型分诊、代码派工」;模型编排 = 「把派工权也交给模型」。


4. 同一个需求,两种编排怎么写 ​

以「制定赛里木湖出行计划」为例。

4.1 Java 编排(M3a 固定工作流,你现在的做法) ​

java
// AssistantApplicationService 里按 intent 分流后,进 MAKE_TRAVEL_PLAN 分支
case MAKE_TRAVEL_PLAN -> {
    // 顺序、条件全是你写死的
    Weather weather   = weatherPort.getForecast(dest, 7);            // 1. 查天气(你决定先查)
    var activities    = activityQuery.searchActivitiesAdvanced(...); // 2. 查活动(你决定后查)
    var guide         = knowledgePort.retrieve(dest, 1);             // 3. 取攻略
    String plan       = llmPort.generatePlan(weather, activities, guide); // 4. 只在这一步用模型「生成」
    return planResult(plan, activities);
}
  • 顺序(天气→活动→攻略→生成)是你决定的。
  • 模型只在最后一步做「把真实数据组织成人话」,且只能用查到的真实活动(grounding,见《幻觉与 grounding》)。
  • 优点:可控、可测、便宜(生成那 1 次是重头,前面查询 0 token)。

4.2 模型编排(M3b 真 Function Calling) ​

用户: 帮我定个赛里木湖出行计划
  → 模型: 我需要天气 → tool_call: get_weather{dest:"赛里木湖", days:7}
  → Java 执行 get_weather,把结果回填给模型
  → 模型: 天气不错,需要活动 → tool_call: search_activities{keyword:"赛里木湖"}
  → Java 执行,回填
  → 模型: 还需要攻略 → tool_call: retrieve_knowledge{query:"赛里木湖"}
  → Java 执行,回填
  → 模型: 信息够了 → 输出最终计划(final answer)
  • 顺序是模型自己决定的(它甚至可能发现下雨就改查室内活动——这是 Java 写死流程做不到的灵活)。
  • 代价:调了 4 次模型(贵、慢),且要防它乱调、调错、死循环。

注意:两种写法里,get_weather / search_activities 的真正执行都是 Java 调你已有的 ApplicationService。 模型编排只是把「下一步调哪个」的决定权交给了模型。


5. Q4:升级到真 Function Calling,我要写什么代码 ​

你担心的「交给模型,我自己要写什么代码」。清单如下(执行工具的代码你现在 M3a 就在写,将来能复用):

5.1 扩 ILlmPort:加一个「带工具的对话」能力 ​

现在的 ILlmPort 只有「抽意图」。真 FC 要加一个方法,能把工具清单告诉模型,并接住模型的 tool_call:

java
public interface ILlmPort {
    IntentExtraction extractSearchIntent(String msg, LocalDate today); // 老的,保留

    // 新增:把工具定义 + 对话历史发给模型,模型要么给最终答案、要么要求调工具
    LlmTurn chatWithTools(List<Message> history, List<ToolSpec> tools);
}
// LlmTurn = 要么 finalText,要么 List<ToolCall>{name, argsJson}

5.2 定义工具 schema(用 JSON 描述你的能力) ​

json
{
  "name": "search_activities",
  "description": "按目的地/时间/预算查询平台内真实活动",
  "parameters": {
    "type": "object",
    "properties": {
      "keyword": {"type": "string"},
      "maxPrice": {"type": "number"},
      "dateStart": {"type": "string", "format": "date"}
    }
  }
}

get_weather / retrieve_knowledge 同理。这些工具背后仍指向你已有的 searchActivitiesAdvanced / IWeatherPort / IKnowledgeRetrievalPort。

5.3 写一个「调度循环」(ReAct loop) ​

这是模型编排的心脏——收到 tool_call 就执行、回填、再问,直到模型给最终答案:

java
List<Message> history = List.of(system, userMsg);
for (int step = 0; step < MAX_STEPS; step++) {        // MAX_STEPS 防死循环
    LlmTurn turn = llmPort.chatWithTools(history, TOOLS);
    if (turn.isFinal()) return turn.getText();        // 模型说够了 → 结束
    for (ToolCall call : turn.getToolCalls()) {
        Object result = dispatch(call);               // ← 还是你的 Java 执行(白名单 + 治理)
        history.add(toolResult(call, result));        // 回填结果给模型
    }
}

5.4 工具治理(分风险等级 + HITL) ​

查询类(search_activities / get_weather)  → 自动执行
写操作(组局 / 报名 / 支付 / 落库)        → 必须人工确认(HITL)后才执行
禁止项(改他人数据 / 导出隐私)            → 直接拒绝

dispatch(call) 里按工具名查这张风险表,高危的先返回「请确认」而不是直接干。

小结:真 FC 你要写的 = ①Port 扩方法 + ②工具 schema + ③调度循环 + ④治理。其中「工具执行」(②背后的真实调用)你 M3a 就写好了,能直接复用。


6. Q4:换模型这块会不会重做 ​

结论:换模型基本不用重做业务,因为编排循环和工具执行都在模型之外。

拆开看,换模型时各部分的命运:

组件换模型要动吗为什么
工具执行(调 ApplicationService)❌ 不动纯业务,和模型无关
工具 schema(JSON 描述)⚠️ 基本不动OpenAI 系 tools 格式基本通用;个别厂商字段名略不同
调度循环(ReAct loop)❌ 不动你写的 Java 控制流
治理(风险分级 / HITL)❌ 不动你写的 Java 规则
ILlmPort 的 Adapter 实现✅ 改这里不同模型/协议的 tool_call 报文格式不同,解析在 Adapter 里
Prompt / few-shot✅ 调这里换模型后跑评测回归,掉分处调

唯一的真风险:新模型不支持 function calling(或支持得很差 / 你用的中转不透传 tools)。这时你退回范式①(结构化输出 + Java 派发)——所以 ILlmPort 最好设计成两种范式都能装(抽意图的老方法 + 带工具的新方法并存),换到弱模型时用老范式兜底。

一句话记住:换模型 = 换「翻译/决策器」,不是重写「业务」。 工具、编排、治理是你的护城河,模型是可插拔的零件。这正是把厂商 SDK 锁在 Infrastructure、Domain 只认 ILlmPort 的价值(ADR-039)。


7. 什么时候该从 Java 编排升到模型编排 ​

别为了「显得高级」就上模型编排。 判据:

继续用 Java 编排(M3a) —— 当任务是「一句话 → 固定流程」:

  • 找活动、查天气、甚至「制定计划」这种步骤基本固定的,Java 的 if/else 写得清清楚楚,可控可测。

升级到模型编排(M3b) —— 当出现这些信号:

  • 任务是开放式多步,步骤和分支多到 if/else 写不过来(「帮我规划 3 天,顺便查天气、比门票价、避开人多的」)。
  • 需要模型根据中间结果动态改主意(查到下雨 → 自己改查室内活动)。
  • 要把工具开放给外部 AI 客户端(Cursor / Claude)复用 → 上 MCP(范式③ / M7)。

TourMate 的现实:C 端「一句话找活动 / 查天气 / 定计划」绝大多数是固定流程,Java 编排够用且更省更稳。真正需要模型编排的是很后面的开放式规划。所以路线是「先把固定工作流跑稳、评测过线(M3a),再逐步把编排权交给模型(M3b)」。


8. 教学收获 ​

8.1 「编排」和「理解」是两件事 ​

模型擅长「理解」(把话变结构),但「编排」(多步决策)在早期交给代码更稳。分清这两件事,才知道哪些交给模型、哪些握在手里。

8.2 「交给模型」交的是决策权,不是执行权 ​

无论哪种编排,执行工具的都是你的 Java。模型再聪明也碰不到数据库——这是「不虚构」的物理保证。所以升级到 FC 不等于「模型接管一切」,你的业务代码一行都少不了。

8.3 隔离在 ILlmPort,换模型才不慌 ​

工具定义、执行、调度循环、治理全在模型外面。换模型只改 Adapter + 跑回归。这就是「AI 应用工程」相对「调模型」的价值分水岭:你在经营模型外面的那层,而不是被某个模型绑架。

8.4 范式要能共存、可降级 ​

ILlmPort 同时容纳「结构化输出派发」和「带工具对话」两种范式,遇到弱模型能降级。不要把架构押死在「模型一定支持 FC」这个假设上。


9. 附录 ​

9.1 术语对照 ​

术语中文说明
Orchestration编排决定多步任务的步骤/工具/顺序
Function Calling / Tool Use函数调用 / 工具调用模型自主吐 tool_call 决定调哪个工具
ReAct推理-行动循环Reason→Act→Observe 反复,直到出答案
Agent智能体能自主编排多工具达成目标的系统
HITL人在回路高危动作先人工确认再执行
MCP模型上下文协议把工具开放给外部 AI 客户端复用(范式③)

9.2 三种「给模型用工具」的范式(速查) ​

①结构化输出 + Java 派发(本项目)②Function Calling③MCP
谁决定调哪个工具Java模型模型(跨进程)
调 LLM 次数1≥2≥2
成本/时延最低中中高
可控性最高中中
里程碑M3aM3bM7

9.3 项目内关联 ​


10. M3b 落地实录(2026-08-18):协议代码地图 + 双通道路由 ​

§4~§6 里写的「将来」已经做出来了(ADR-042)。这一节把「代码里到底在哪」和「什么时候走哪条」讲死,回答你三个问题:①开了 agent 是不是就不走 Java 编排了 ②Java 编排还有用吗 ③tools/tool_calls/tool 协议在哪几段代码。

10.1 落地了什么 ​

件代码
低阶「带工具对话」方法(保留老的抽意图)ILlmPort.chat(messages, tools)(默认方法,旧实现零改动即可编译)
4 个纯 POJO 契约LlmMessage / ToolSpec / ToolCall / LlmChatResult(domain/.../assistant/model/)
真实模型手写 tool-callingOpenAiCompatibleLlmAdapter.chat(发 tools / 收 tool_calls)
确定性打桩(无 Key 也能跑循环)MockLlmAdapter.chat
ReAct 调度循环 + 工具注册表AssistantApplicationService.agentChat / agentTools / executeTool
首批工具search_activities / get_weather / get_activity_detail(后者复用 getActivityById)
成本护栏tourmate.assistant.agent.enabled(默认关)+ .max-iterations

10.2 协议代码地图:tools / tool_calls / tool role 到底在哪几段 ​

一次「查活动」要经过两轮模型往返,OpenAI 兼容协议的 6 个环节各自的落点:

环节OpenAI 报文长啥样代码位置
① 声明工具(发 tools)请求体 tools:[{type:"function",function:{name,description,parameters}}] + tool_choice:"auto"OpenAiCompatibleLlmAdapter.buildChatRequestBody / toJsonTool;工具清单来自 AssistantApplicationService.agentTools()
② 模型要调工具(收 tool_calls)响应 choices[0].message.tool_calls:[{id,function:{name,arguments}}],finish_reason="tool_calls"OpenAiCompatibleLlmAdapter.parseChatResult(解析成 List<ToolCall>,arguments JSON 串 → Map 供 Domain 直读)
③ 回写 assistant 的 tool_calls(多轮上下文对齐,必做)历史里补一条 {role:"assistant", tool_calls:[...]}agentChat → LlmMessage.assistantToolCalls;序列化在 toJsonMessage(role=assistant)
④ 执行工具(你的 Java,不经过模型)——AssistantApplicationService.executeTool → execSearch/execWeather/execDetail(调既有查询/天气 Port)
⑤ 回喂工具结果(tool role)历史里补 {role:"tool", tool_call_id, content}(tool_call_id 必须对齐②的 id)LlmMessage.toolResult;序列化在 toJsonMessage(role=tool)
⑥ 循环到终答重复 ①~⑤,直到 finish_reason="stop" 出 contentagentChat 的 for (iter < max-iterations) 循环

报文时序(一次「赛里木湖有什么活动」,简化):

第1轮 chat(messages=[system,user], tools=[3个])
  ← 模型: finish_reason=tool_calls, tool_calls=[{id:call_1, name:search_activities, arguments:{"keyword":"赛里木湖"}}]
  → Java: executeTool → searchActivitiesAdvanced → 2 条真活动(收进 cards)
  → messages += {role:assistant, tool_calls:[call_1]}          # ③
  → messages += {role:tool, tool_call_id:call_1, content:"找到2个真实活动 activityId=.."}  # ⑤
第2轮 chat(messages=[...,assistant,tool], tools=[3个])
  ← 模型: finish_reason=stop, content:"给你找到两个赛里木湖的活动~"   # ⑥ 终答
  → 返回 replyText + activityCards(来自 cards,grounding:模型碰不到 id)

关键:tool_call_id 是把「②模型说要调哪个」和「⑤这是那次调用的结果」缝起来的线。少了③(不把 assistant 的 tool_calls 写回历史)很多厂商会报「tool 消息没有对应的 tool_calls」。

10.3 双通道:什么时候走 Java 编排、什么时候走模型编排 ​

先说现状(本次实现)= 一个全局开关,不是按请求路由:

  • agent.enabled=false(默认)→ 整条 chat 走 Java 编排(意图派发,M3a):抽 1 次意图 → switch(intent) 分流。
  • agent.enabled=true → 整条 chat 走模型编排(Agent 循环,M3b):此时完全不走 intent 那套 switch,understood 返回 null、meta.toolCalled=agent。

所以你担心的「Java 编排是不是白写了 / 用不上了」——没白写,两层原因:

  1. 它现在仍是默认线:生产默认走 Java 编排(稳、便宜、可评测);Agent 是可灰度打开的进阶线。关掉 agent,行为和现在一模一样。
  2. 成熟形态是「路由并存」而不是「二选一淘汰」:生产级 Agent 系统几乎都保留「先分诊、再决定走固定流程还是交给模型」。高危、可枚举、图便宜的永远走固定流程;只有开放式多步才交给模型。所以 Java 编排是长期存在的一条腿,不是过渡品。

决策矩阵(该走哪条):

信号走哪条为什么
意图明确、流程固定(查天气、简单找活动、做计划)Java 编排1 次模型调用、确定性、能进评测门禁
高危写操作(报名 / 支付 / 组局落库)Java 编排(+ 校验 + 幂等 + Saga)绝不能让模型自由决定副作用,必须固定流程
成本 / 时延敏感Java 编排Agent 循环 N 次模型调用,更贵更慢
需要可回归的评测锚点Java 编排只有确定性子集能做门禁
开放式、多步、要组合多工具、按中间结果改主意模型编排if/else 写不过来;模型能动态规划(下雨→改室内)
需求边界模糊、意图不可枚举模型编排固定意图枚举覆盖不了

一句话:能枚举 + 要合规 + 图便宜 → Java;开放式 + 多步 + 要灵活 → 模型。 TourMate 现在约九成是前者,所以默认 Java 编排,Agent 备着长本事、接后面的开放式规划。

10.4 尚缺 + 下一步 ​

  • 工具风险分级 + HITL(人在回路):现在三个工具全是只读(search/weather/detail),没有副作用,所以 HITL 暂时无对象;等接入「报名/组局」这类写操作工具,必须给工具打 riskTier,高危的先返回「请确认」而非直接执行。这是 M3b 真正的收尾。
  • 每次 tool call 落审计:目前每次工具调用是结构化 log.info,可进一步写 IAssistantAuditPort。
  • 全局开关 → 按场景路由:把入口升级为「先便宜地判一次路由,简单固定走 Java、开放多步走 Agent」,既省钱又不失灵活——这才是生产级双通道的完全体。(当前先做全局开关,够学、够灰度。)
  • Agent 通道接会话记忆:现 Agent 循环未接 AssistantSession(多轮补槽),后续可打通。

Powered by VitePress