助手 · 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. 场景:一个「制定出行计划」引出的问题
- 2. 什么是「编排」
- 3. Java 编排 vs 模型编排
- 4. 同一个需求,两种编排怎么写
- 5. Q4:升级到真 Function Calling,我要写什么代码
- 6. Q4:换模型这块会不会重做
- 7. 什么时候该从 Java 编排升到模型编排
- 8. 教学收获
- 9. 附录
- 10. M3b 落地实录(协议代码地图 + 双通道路由)
1. 场景:一个「制定出行计划」引出的问题
用户说:「帮我定个去赛里木湖的出行计划」。这一句要做好几件事:
- 查赛里木湖未来几天天气(能不能去)
- 查平台里赛里木湖相关活动(有啥可报名)
- 取赛里木湖攻略(最佳季节、注意事项)
- 把上面这些真实信息喂给模型,生成一份每日计划
问题来了:「先查天气还是先查活动?查完天气要不要根据天气再决定查哪些活动?这个决定谁来做?」 —— 这就是「编排」。
- 现在(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 |
| 对应里程碑 | M3a | M3b / M7 |
核心一句:Java 编排 = 「模型分诊、代码派工」;模型编排 = 「把派工权也交给模型」。
4. 同一个需求,两种编排怎么写
以「制定赛里木湖出行计划」为例。
4.1 Java 编排(M3a 固定工作流,你现在的做法)
// 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:
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 描述你的能力)
{
"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 就执行、回填、再问,直到模型给最终答案:
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 |
| 成本/时延 | 最低 | 中 | 中高 |
| 可控性 | 最高 | 中 | 中 |
| 里程碑 | M3a | M3b | M7 |
9.3 项目内关联
- 意图识别(编排的上游):
助手-意图识别是怎么做到的 - 生成型意图防编造:
助手-大模型幻觉与grounding - 里程碑拆解 M3a/M3b: 北极星与终局蓝图 §6.1
- 编排落地代码:
AssistantApplicationService.chat/.agentChat
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-calling | OpenAiCompatibleLlmAdapter.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" 出 content | agentChat 的 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 编排是不是白写了 / 用不上了」——没白写,两层原因:
- 它现在仍是默认线:生产默认走 Java 编排(稳、便宜、可评测);Agent 是可灰度打开的进阶线。关掉 agent,行为和现在一模一样。
- 成熟形态是「路由并存」而不是「二选一淘汰」:生产级 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(多轮补槽),后续可打通。