Agent工具调用实现原理

本文介绍 AI 助手(Agent)从「用户说一句话」到「模型调用工具、异步任务跑完、结果回到对话」的完整链路。

代码片段为简化示意,不代表线上原样。

1. 要解决的问题

一个对话式 AI 产品,模型本身只能生成文本。要让它真正「做事」,需要解决四件事:

  1. 模型知道能做什么:把平台能力以工具表(JSON Schema)的形式交给模型;
  2. 模型能安全地发起动作:工具参数校验、归属校验、计费冻结、异步派发;
  3. 动作是长任务:视频渲染、PPT 生成、语音合成动辄几十秒到几十分钟,不能卡住对话;
  4. 结果能回到对话并持久化:刷新页面、换设备、过几天回来,任务卡和产物还在。

本文的链路把这些问题拆成「同步的对话循环」+「异步的任务系统」两层。

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. 一次轮次的完整时序

  1. 前端 POST /agent/turns,body 只有 sessionId 和 content;
  2. 服务端校验会话归属,启动异步轮次,返回 SSE;
  3. 从数据库重建「这一轮之前」的消息数组,解析本轮附件;
  4. 每一轮:
    • 注入系统提示词与工具表;
    • 调上游模型(流式),拿回 assistant 文本和 tool_calls;
    • 先推 assistant 事件,再推 tool_call;
    • 逐个串行执行工具(一次一个,防重复、防并发冻结);
    • 推 tool_result,把结果作为 role:tool 消息回灌给模型;
    • 模型决定继续调工具,或给出最终答复;
  5. 到达最大轮数 / 模型不再调工具 / 用户中断 → 推 done + [DONE] 关闭流;
  6. 异步任务在 worker 跑完后回调后端,落状态、结算;
  7. 前端轮询任务状态,任务卡和产物出现在对话里。

4. 工具注册:Spring Bean 即工具

每个工具是一个 Spring Bean,实现统一的 AgentTool 接口。注册表在启动时一次性收齐所有工具:

java 复制代码
@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,状态轮询/刷新恢复要靠它找回工具;这也让「新增工具」不需要改前端的任务解析逻辑。

工具接口本身只包含四件事:

java 复制代码
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 工具表按「模型看得见的素材」收窄

不是每个工具在任何时候都可用。比如「人声分离」需要音频,「换脸」需要视频或图片。如果全量注入,模型会看到一个根本跑不起来的工具,选中后只换来一句「请先上传附件」------既浪费注意力,也让用户以为平台坏了。

java 复制代码
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 系统提示词与工具表同源注入

java 复制代码
public void injectIfAgent(ChatCompletionRequest request) {
    if (!isAgentClient(request)) return;   // metadata.client = agent
    injectSystemPrompt(request);           // 放在最前面
    injectTools(request);                  // 仅在请求未自带 tools 时
}

两者必须一起注入:提示词描述的是这张工具表。分开注入迟早出现「提示词说有、工具表里没有」的中间态,模型只能按工具表回答,用户看到的就是「平台不支持」。

工具表注入有两条保护:

  • 请求已自带 tools 时不覆盖------代理对其他接入方保持透明;
  • 默认 parallel_tool_calls = false------服务端循环是串行执行工具调用的,声明并行会让模型以为三件事一起做,实际是排队,延迟照样叠加。

5. 服务端循环

循环本体在 AgentTurnService,只关心「什么时候发什么、什么时候停」:

java 复制代码
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));

几个容易踩坑的点:

  1. 用户中断是 best-effort :已经发出的模型调用无法中途取消,但每个检查点(拿到响应后、执行工具前)都会看 isAborted()。用户点了停止,不能继续替他下单冻结额度。
  2. 只追加「已执行」的调用 :中断可能停在半路,多写一条没有对应 role:tool 的 tool_call,下一次请求就是非法消息序列。
  3. 结果回灌必须是 JSON 文本 :不能用 Map.toString() 的 Java 语法;轮内和跨轮(从库里重建)必须同一种形状,否则同一份结果在模型眼里有两种说法。
  4. 界面块不进模型上下文 :工具结果里的表格/图(blocks)只给前端渲染,回灌时摘掉;正文 summary 已经包含同一份内容,重复喂只会多花 token。

6. 工具契约:失败也是一等公民

工具执行入口只有一个,且绝不抛异常:

java 复制代码
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 反查记录。

任务卡的模板(阶段、标题)必须写回记录,否则刷新后不存在:

java 复制代码
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 的骨架一致:

python 复制代码
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) 从记录里重建消息数组。逐行规则:

  1. 任务卡行跳过:它的产物和阶段属于卡片,不是对话上下文;
  2. 信封里的 prompt 非空 → 一条 user 消息(同一轮只写一次,后续往返为空串 + follow_up 标记);
  3. result_url → assistant 文本;
  4. payload 里有工具调用链 → 补 assistant.tool_calls 和逐条 role:tool 结果,顺序不能乱;
  5. 老会话没有工具链落库 → 退化成纯文本上下文,对话仍然连贯。

这里最硬的一条约束是:每个 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. 新增一个工具的检查清单

  1. 实现 AgentTool Bean :name() 唯一、serviceTypes() 不与其他工具冲突;
  2. 写清 description():什么时候用、参数从哪来、失败时怎么提示、是否需要附件、价格口径;
  3. 定义 JSON Schema:必填项尽量少,枚举收窄,不要暴露内部模型名/地址;
  4. 参数校验与失败文案 :失败返回 fail(code, message),文案写给模型看(缺什么、可选值有哪些);
  5. 任务型工具 :建记录 + 冻结 + 派发 + AgentTaskCardWriter.write,返回 recordId/stages/summary;
  6. worker:补消息 schema、handler、池路由、回调(幂等、带归属校验、终态不可回退);
  7. 前端:通常只需要加一个工具名 → 展示文案的映射;产物类型走通用 artifact 归一;
  8. 测试:注册表/装配环 ContextTest、工具表 Lint、真实素材的端到端(成功、失败、退款、刷新恢复);
  9. 提示词:能力清单或工具边界变化时更新系统提示词并升版本号,先发后端再发前端。

13. 设计取舍

  • 为什么循环在服务端:前端循环意味着工具表、提示词、计费、归属校验都暴露在客户端;服务端循环让能力演进不用等前端发版,也能在工具执行层统一加授权/风控。
  • 为什么失败也是结果:对话式产品最怕「一句话把整轮打断」。失败回灌给模型,模型可以改参数、换工具或解释,用户体验是连续的。
  • 为什么工具表要按素材收窄:模型注意力有限,藏起跑不起来的工具,比让它选中后再报错更好。
  • 为什么任务卡要落库:长任务的结果不是一次性消息,而是用户会回来查看、下载、继续编辑的资产。记录 + payload + 产物 URL 是它的最小持久化形态。
  • 为什么回调要幂等/终态保护:网络重试、worker 重启、回调迟到都是常态;结算路径必须能承受同一份 payload 到达多次。

附:产品展示

相关推荐
白雪讲堂1 小时前
企业如何获得AI长期调用权?《GEO实战:AI时代的增长密码》提出系统经营路径
人工智能·geo·geo从入门到精通
AI视频工具指南1 小时前
怎么用 AI 做一两分钟的小故事视频?
人工智能·aigc·音视频
深圳市快瞳科技有限公司1 小时前
面向端侧部署的多帧宠物身份识别:完整性评分机制与工程约束
人工智能·算法·计算机视觉·端侧ai·宠物识别·宠物ai·宠物身份识别
szxinmai主板定制专家1 小时前
软硬协同!ZYNQ 高速多通道同步采集,变电站 / 工厂设备状态监测方案详解
人工智能·深度学习·机器学习·fpga开发·zynq·rk3576
FL16238631291 小时前
古建筑缺陷检测数据集VOC+YOLO格式4018张6类别
人工智能·yolo·机器学习
愤怒火龙果1 小时前
张小珺商业访谈笔记整理|对话 Dyna 创始人 Lindon Gao:机器人世界的迷雾、分歧与抉择
人工智能·机器人·人物访谈
云和数据.ChenGuang2 小时前
print(f“a+b=“,result) print(f“a+b={result}“)区别
java·前端·人工智能·python·深度学习
roman_日积跬步-终至千里2 小时前
【数据Agent论文·查询与语义|11】EntSQL:给了业务文档,模型为何还是写不对SQL?
数据库·人工智能·sql·数据分析
一木 之林2 小时前
LangChain 第9章 RAG 与项目实战:文档切分、嵌入、Milvus 与客服知识库
人工智能·计算机视觉·langchain·milvus