本文介绍 AI 助手(Agent)从「用户说一句话」到「模型调用工具、异步任务跑完、结果回到对话」的完整链路。
代码片段为简化示意,不代表线上原样。
1. 要解决的问题
一个对话式 AI 产品,模型本身只能生成文本。要让它真正「做事」,需要解决四件事:
- 模型知道能做什么:把平台能力以工具表(JSON Schema)的形式交给模型;
- 模型能安全地发起动作:工具参数校验、归属校验、计费冻结、异步派发;
- 动作是长任务:视频渲染、PPT 生成、语音合成动辄几十秒到几十分钟,不能卡住对话;
- 结果能回到对话并持久化:刷新页面、换设备、过几天回来,任务卡和产物还在。
本文的链路把这些问题拆成「同步的对话循环」+「异步的任务系统」两层。
2. 总体架构
┌──────────┐ POST /agent/turns (SSE) ┌────────────────────┐
│ Browser │ ─────────────────────────────▶ │ AgentTurnService │
│ 对话页 │ ◀───────────────────────────── │ 服务端 tool 循环 │
└──────────┘ round/assistant/tool_call/ └─────────┬──────────┘
│ tool_result/error/done │
│ │ 1) 注入系统提示词 + 工具表
│ ▼
│ ┌────────────────────┐
│ │ Model Provider │
│ │ (chat.completions) │
│ └─────────┬──────────┘
│ │ tool_calls
│ ▼
│ ┌────────────────────┐
│ │ AgentToolExecution │
│ │ Service(唯一入口)│
│ └─────────┬──────────┘
│ │
│ 同步工具(只读) │ 异步任务工具
│ list/read/query │ TTS/视频/PPT/图片…
│ ▼
│ ┌────────────────────┐
│ │ InteractiveRecord │ 建记录 + 冻结额度
│ │ + SSEController │ 派发消息给 worker
│ └─────────┬──────────┘
│ │
│ ▼
│ ┌────────────────────┐
│ │ Worker(独立进程) │ 执行真正的重活
│ │ 快池 / 慢池 / deck │
│ └─────────┬──────────┘
│ │ 回调:状态 / 产物 / 计量值
│ ▼
│ ┌────────────────────┐
└────────── 轮询任务状态 ◀────────────── │ 记录落库 + 结算 │
└────────────────────┘
三条关键边界:
- 浏览器不掌握工具表、系统提示词和历史上下文:只发「哪个会话 + 用户说了什么」;
- 模型只负责「选工具 + 填参数」:真正的执行、计费、归属校验都在服务端;
- 长任务不占对话线程:工具提交后立刻返回
recordId,对话继续;产物由任务卡和轮询接管。
3. 一次轮次的完整时序
- 前端
POST /agent/turns,body 只有sessionId和content; - 服务端校验会话归属,启动异步轮次,返回 SSE;
- 从数据库重建「这一轮之前」的消息数组,解析本轮附件;
- 每一轮:
- 注入系统提示词与工具表;
- 调上游模型(流式),拿回
assistant文本和tool_calls; - 先推
assistant事件,再推tool_call; - 逐个串行执行工具(一次一个,防重复、防并发冻结);
- 推
tool_result,把结果作为role:tool消息回灌给模型; - 模型决定继续调工具,或给出最终答复;
- 到达最大轮数 / 模型不再调工具 / 用户中断 → 推
done+[DONE]关闭流; - 异步任务在 worker 跑完后回调后端,落状态、结算;
- 前端轮询任务状态,任务卡和产物出现在对话里。
4. 工具注册:Spring Bean 即工具
每个工具是一个 Spring Bean,实现统一的 AgentTool 接口。注册表在启动时一次性收齐所有工具:
@Component
public class AgentToolRegistry {
private final Map<String, AgentTool> byName = new LinkedHashMap<>();
private final Map<String, AgentTool> byServiceType = new HashMap<>();
public AgentToolRegistry(List<AgentTool> tools) {
for (AgentTool tool : tools) {
if (byName.put(tool.name(), tool) != null) {
throw new IllegalStateException("duplicate tool name: " + tool.name());
}
for (String serviceType : tool.serviceTypes()) {
if (byServiceType.put(serviceType, tool) != null) {
throw new IllegalStateException("duplicate service_type: " + serviceType);
}
}
}
}
}
这里有两个刻意的设计:
- 重复就启动失败:工具重名会让模型调用落到错误实现;
service_type重复更隐蔽——任务卡照建,刷新后却找不到工具,进度永远停在提交那一刻。宁可在启动时炸掉。 - 服务类型反查工具:任务行只有
service_type,状态轮询/刷新恢复要靠它找回工具;这也让「新增工具」不需要改前端的任务解析逻辑。
工具接口本身只包含四件事:
public interface AgentTool {
String name(); // OpenAI function name
String description(); // 给模型看:什么时候用、参数从哪来
Map<String, Object> parameters(); // JSON Schema
AgentToolResult execute(AgentToolContext ctx, Map<String, Object> args);
default List<String> serviceTypes() {
return List.of(); }
default Set<String> consumesMediaTypes() {
return Set.of(); }
}
4.1 工具表按「模型看得见的素材」收窄
不是每个工具在任何时候都可用。比如「人声分离」需要音频,「换脸」需要视频或图片。如果全量注入,模型会看到一个根本跑不起来的工具,选中后只换来一句「请先上传附件」——既浪费注意力,也让用户以为平台坏了。
private static boolean isAvailable(AgentTool tool, Set<String> visibleMediaTypes) {
Set<String> consumes = tool.consumesMediaTypes();
if (consumes.isEmpty()) return true; // 不依赖附件,恒可用
if (visibleMediaTypes == null || visibleMediaTypes.isEmpty()) return false;
return consumes.stream().anyMatch(visibleMediaTypes::contains);
}
consumesMediaTypes() 为空集表示「任何时候都可用」——文字生图这类工具就属于这一类。
素材类型从整段消息里的附件行解析,而不是只看本轮最后一条:用户只在传文件的那一轮写过 [附件N],下一轮说「把刚才那个视频再截 10 秒」时,当轮一个附件行都没有。只看本轮会把一个本来能用的工具藏起来。解析走和工具执行同一个函数,保证「摆在模型面前的工具」和「工具真能取到的附件」不会各算各的。
4.2 系统提示词与工具表同源注入
public void injectIfAgent(ChatCompletionRequest request) {
if (!isAgentClient(request)) return; // metadata.client = agent
injectSystemPrompt(request); // 放在最前面
injectTools(request); // 仅在请求未自带 tools 时
}
两者必须一起注入:提示词描述的是这张工具表。分开注入迟早出现「提示词说有、工具表里没有」的中间态,模型只能按工具表回答,用户看到的就是「平台不支持」。
工具表注入有两条保护:
- 请求已自带
tools时不覆盖——代理对其他接入方保持透明; - 默认
parallel_tool_calls = false——服务端循环是串行执行工具调用的,声明并行会让模型以为三件事一起做,实际是排队,延迟照样叠加。
5. 服务端循环
循环本体在 AgentTurnService,只关心「什么时候发什么、什么时候停」:
List<ChatMessage> messages = new ArrayList<>(contextBuilder.buildHistory(sessionId));
messages.add(ChatMessage.userMessage(request.getContent()));
for (int round = 1; round <= maxRounds; round++) {
if (stream.isAborted()) break;
ChatCompletionRequest req = buildRequest(model, messages, metadata);
ExecutionContext ctx = orchestrator.startExecution(account, req, /* agentChat */ true);
ChatCompletionResponse response = completeStreaming(req);
String text = response.getReplyText();
if (text != null && !text.isBlank()) {
stream.send("assistant", assistantEvent(round, recordId, text));
}
List<Call> calls = extractToolCalls(response);
Map<String, Object> executed = new LinkedHashMap<>();
List<Map<String, Object>> persisted = new ArrayList<>();
for (Call call : calls) {
if (stream.isAborted()) break;
stream.send("tool_call", toolCallEvent(round, call));
// 同一轮里完全相同的 name+arguments 只执行一次:否则会下两笔单、冻两份额度
String key = call.name() + "|" + call.arguments();
Map<String, Object> payload = executed.get(key);
if (payload == null) {
AgentToolResult result = executionService.execute(
account, call.name(), call.arguments(), sessionId, turnId, attachments);
payload = toPayload(result);
executed.put(key, payload);
} else {
payload = duplicated(payload); // 回一条 notice:重复调用被忽略
}
persisted.add(toolCallEntry(call, payload));
stream.send("tool_result", toolResultEvent(round, call, payload));
}
if (!persisted.isEmpty()) {
record.setPayload(serialize(Map.of("toolCalls", persisted)));
}
orchestrator.completeExecution(ctx, req, response);
if (calls.isEmpty()) break; // 模型没调工具:这就是最终答复
appendRoundToMessages(messages, text, persisted);
}
stream.sendDoneAndClose(doneEvent(turnId, roundsRun, recordIds));
几个容易踩坑的点:
- 用户中断是 best-effort:已经发出的模型调用无法中途取消,但每个检查点(拿到响应后、执行工具前)都会看
isAborted()。用户点了停止,不能继续替他下单冻结额度。 - 只追加「已执行」的调用:中断可能停在半路,多写一条没有对应
role:tool的tool_call,下一次请求就是非法消息序列。 - 结果回灌必须是 JSON 文本:不能用
Map.toString()的 Java 语法;轮内和跨轮(从库里重建)必须同一种形状,否则同一份结果在模型眼里有两种说法。 - 界面块不进模型上下文:工具结果里的表格/图(
blocks)只给前端渲染,回灌时摘掉;正文summary已经包含同一份内容,重复喂只会多花 token。
6. 工具契约:失败也是一等公民
工具执行入口只有一个,且绝不抛异常:
public AgentToolResult execute(String account, String name, Object rawArgs,
Integer sessionId, Integer turnId,
List<MediaAttachment> attachments) {
try {
AgentTool tool = registry.get(name);
if (tool == null) return fail("未知工具");
if (sessionId != null && !ownsSession(account, sessionId)) {
return fail("会话不存在或不属于当前用户");
}
Map<String, Object> args = parseArguments(rawArgs); // JSON 字符串或对象
return normalize(tool.execute(new AgentToolContext(account, sessionId, turnId, attachments), args));
} catch (IllegalArgumentException e) {
return fail(e.getMessage());
} catch (Exception e) {
log.error("tool execution failed: {}", name, e);
return fail("工具执行失败,请稍后重试");
}
}
失败不抛异常的原因:失败结果会作为一条 role:tool 消息回灌给模型,让模型自己改参数、换工具或向用户解释。如果把异常抛出去,整轮对话就断了——模型失去了自我纠错的机会。
工具参数里的附件只认序号:
[附件1] media_url=oss://… media_type=audio duration_seconds=18 file_name=录音.m4a
[附件2] media_url=oss://… media_type=video duration_seconds=32 file_name=素材.mp4
模型填 source_ref=1,服务端从同一行里取出地址、类型、时长。于是两件事同时成立:模型编不出地址;也不会把 A 的地址配上 B 的时长(多文件任务里这是最容易犯、又直接决定冻结多少钱的错)。
7. 任务型工具:同步对话 + 异步执行
同步工具(列举形象/音色、读文档、查表)直接返回结果。任务型工具的共同模式是:
校验参数 → 建记录 + 冻结额度 → 派发 SSE 消息 → 立即返回 recordId
↓
worker 异步执行
↓
回调:状态 / 产物 / 计量值
↓
落库 + 结算 + 任务卡更新
工具返回给前端和模型的 AgentToolResult 同时承载三样东西:
- 给模型看的:
summary(一句话说清做成了什么、记录 ID 是多少); - 给界面看的:
toolKey、title、stages、noticeText、recordId、serviceType; - 给状态轮询看的:
serviceType反查工具,recordId反查记录。
任务卡的模板(阶段、标题)必须写回记录,否则刷新后不存在:
AgentTaskCardWriter.write(recordService, record, TOOL_KEY, TITLE, NOTICE_TEXT, STAGES);
记录行用 payload 里的 recordKind=task 正面声明自己是任务卡,而不是靠「prompt 里有没有 model」猜——后者会把业务入参自带 model 字段的语音合成任务行误判成对话行。
8. Worker 侧:消息协议与三档线程池
后端与 worker 之间走一条极简的定长分隔消息:
task_type$ACCOUNT$RECORD$ASSET[$BASE64_OPTIONS]
ACCOUNT/RECORD都是不透明标识;- 地址、自由文本、可能含
$/换行的参数一律 base64; - worker 启动时有一张
_MESSAGE_SCHEMA,只校验必需段数量——新旧两端可以各自上线,多出来的尾部段老代码自然忽略。
worker 按任务的耗时/资源特征分三档线程池:
| 池 | 典型任务 | 原因 |
|---|---|---|
| 快池 | TTS、上传、单张图片抠图 | 秒级;不能被长任务堵住 |
| 慢池 | 视频渲染、口型、人声分离、文生图 | 分钟级 / 独占 GPU;不能堵住快池 |
| deck 池 | PPT 大纲/展开/导出 | 网络等待型,等模型 20–60 秒 |
每个 handler 的骨架一致:
def handle_task(msgs, account, headers, update_status, api_server):
record = msgs[2]
update_status(record, account) # QUEUE → PROCESSING
try:
result = do_real_work(msgs)
url = upload(result)
status, error = "COMPLETED", None
except Exception as e:
status, error = "FAILED", str(e)
send_callback(api_server, {
"id": record, "status": status,
"resultUrl": url, "quota": UNITS,
"errorMessage": error,
}, headers)
回调侧必须校验归属:只凭自增 ID 就能改状态的话,任何人都能替别人「完成」或「失败」一单,失败还会触发退款。心跳与看门狗另走一条轻量接口,只更新 update_date,不动状态、不触发结算。
9. 上下文重建:刷新后还能接着聊
前端不发历史,服务端按 (sessionId, turnId) 从记录里重建消息数组。逐行规则:
- 任务卡行跳过:它的产物和阶段属于卡片,不是对话上下文;
- 信封里的
prompt非空 → 一条user消息(同一轮只写一次,后续往返为空串 +follow_up标记); result_url→assistant文本;- payload 里有工具调用链 → 补
assistant.tool_calls和逐条role:tool结果,顺序不能乱; - 老会话没有工具链落库 → 退化成纯文本上下文,对话仍然连贯。
这里最硬的一条约束是:每个 tool_call_id 必须紧跟一条对应的 role:tool。少一条,下一次请求就是非法消息序列。因此轮内追加和跨轮重建都先把调用链归一成同一个 Call 结构,再由同一份代码拼出上游格式。
10. 前端协作:只消费事件,不参与决策
前端 POST /agent/turns 后解析 SSE:
| 事件 | 前端行为 |
|---|---|
round |
记录本轮开始(排查用) |
assistant |
累加到助手气泡 |
tool_call |
显示「正在调用 X」一行轻提示 |
tool_result |
成功且有 recordId → 建任务卡并开始轮询;只读工具 → 渲染表格/图块 |
error |
写入气泡错误文案;特定错误码给「去充值」入口 |
done + [DONE] |
结束解析 |
任务卡由 recordId 驱动轮询;产物按 URL 后缀归一成图片/视频/音频/文件,交给统一的渲染器。前端不解析工具名来决定怎么渲染——新增一个工具,只要返回标准的 AgentToolResult,任务卡、产物、下载都不需要改前端。
11. 安全与一致性
- 身份与归属:入口用
@Md5鉴权;工具执行前校验会话归属;回调校验记录归属。任何一处缺失,都能被用来「替别人下单」或「自助退款」。 - URL 白名单:会进 worker 的素材地址先过校验,避免把内网地址/任意协议带进下载器(SSRF)。
- 模型编不出地址:附件只传序号,地址由服务端从附件行里取;生成类工具返回的产物地址由服务端上传后写入,不由模型生成。
- 重复调用去重:同一轮里
name+arguments完全相同的调用只执行一次,避免重复冻结/重复下单。 - 终态不可回退:回调迟到不能把
COMPLETED拽回PROCESSING,否则会重新武装结算判据,导致多扣或多退。 - 结算不变式:创建时冻结额与记录 quota 同值;中间态不改 quota;首次进入终态才结算(
quota × rate),最终失败才退款。结算路径不做余额准入,任何异常都必须吞掉,不能毒掉调用方事务。
12. 新增一个工具的检查清单
- 实现
AgentToolBean:name()唯一、serviceTypes()不与其他工具冲突; - 写清
description():什么时候用、参数从哪来、失败时怎么提示、是否需要附件、价格口径; - 定义 JSON Schema:必填项尽量少,枚举收窄,不要暴露内部模型名/地址;
- 参数校验与失败文案:失败返回
fail(code, message),文案写给模型看(缺什么、可选值有哪些); - 任务型工具:建记录 + 冻结 + 派发 +
AgentTaskCardWriter.write,返回recordId/stages/summary; - worker:补消息 schema、handler、池路由、回调(幂等、带归属校验、终态不可回退);
- 前端:通常只需要加一个工具名 → 展示文案的映射;产物类型走通用 artifact 归一;
- 测试:注册表/装配环 ContextTest、工具表 Lint、真实素材的端到端(成功、失败、退款、刷新恢复);
- 提示词:能力清单或工具边界变化时更新系统提示词并升版本号,先发后端再发前端。
13. 设计取舍
- 为什么循环在服务端:前端循环意味着工具表、提示词、计费、归属校验都暴露在客户端;服务端循环让能力演进不用等前端发版,也能在工具执行层统一加授权/风控。
- 为什么失败也是结果:对话式产品最怕「一句话把整轮打断」。失败回灌给模型,模型可以改参数、换工具或解释,用户体验是连续的。
- 为什么工具表要按素材收窄:模型注意力有限,藏起跑不起来的工具,比让它选中后再报错更好。
- 为什么任务卡要落库:长任务的结果不是一次性消息,而是用户会回来查看、下载、继续编辑的资产。记录 + payload + 产物 URL 是它的最小持久化形态。
- 为什么回调要幂等/终态保护:网络重试、worker 重启、回调迟到都是常态;结算路径必须能承受同一份 payload 到达多次。
附:产品展示