Agent工具调用实现原理

简介: 本文详解AI Agent从用户输入到工具调用、异步执行、结果回填的完整链路,涵盖同步对话循环与异步任务系统的分层架构、工具注册与安全执行、上下文重建、任务持久化等核心设计,240字

本文介绍 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 接口。注册表在启动时一次性收齐所有工具:

@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));

几个容易踩坑的点:

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

  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 到达多次。

附:产品展示
在这里插入图片描述

相关文章
|
3天前
|
SQL 人工智能 网络安全
别再熬夜做 PPT 了!输入一个主题,AI 帮你把整套幻灯片做好,还能随便改字
这是一款专注「内容→PPTX」的AI幻灯片工具:输入主题或文档,秒生成可自由编辑的整套PPT;网页端支持拖拽、改字、换图,导出为真正可编辑的`.pptx`或高保真`.pdf`。不拼颜值,只求编辑爽、导出准。(239字)
41 2
|
17小时前
|
人工智能 自然语言处理 文字识别
Agent 对话里的图片与视频处理
2026年10月17日上线!AI助手对话页全新支持图片/视频智能处理:抠图、放大修复、文字识别、换脸、画质增强等,自然语言指令或快捷按钮一键触发,任务卡内直接预览下载,无需跳转独立页面。(239字)
29 1
|
1天前
|
存储 运维 安全
Agent Memory 开发设计:从入门到避坑
Agent记忆不是简单接向量库!本文系统剖析记忆工程核心:四大记忆类型(工作/情景/语义/程序)需分层存储;用双时态建模解决时间冲突;以“可追溯、带置信、有状态”的信念管理替代LLM真值判断;强调安全写入、证据驱动检索与可观测治理。记忆即认知基础设施。
39 0
|
3天前
|
人工智能
你的 PPT 不缺内容,缺一个讲它的人
这是一款智能文档视频化工具:上传PPT/PDF,AI按页解析、生成讲解词,支持逐页编辑润色、独立配音、数字人图层自由拖拽。配音按字符计费,合成按分钟计费,预览不重复扣费。专为已有内容需高效讲解的场景设计,省时省力不省质。(239字)
22 0
|
3天前
|
SQL JSON 人工智能
可编辑 PPT:调研、设计与实施方案
本文为内部设计文档,规划全新「内容→PPTX」产品线:输入主题或文档,AI生成整套幻灯片,支持网页自由编辑(拖拽/换版式/调样式),导出真正可编辑的 `.pptx`(文字、框、图均可改)和保真 `.pdf`。区别于现有「PPTX→MP4」路线,本版聚焦编辑与导出,不支持配音、视频、双向同步。调研排除闭源/许可受限方案,全栈自研,确保可控与合规。(239字)
63 0
|
5天前
|
Web App开发 人工智能 Rust
被剪映逼疯的2亿创作者,终于等来了一个“掀桌子”的人
剪映付费墙高筑,创作者深陷“工具焦虑”。开源项目OpenCut应运而生:免费、本地处理、无水印、零上传,操作类剪映,浏览器即开即用。虽功能尚简、稳定性待提升,但已成隐私与自由的可靠备选。8.7万+ Star见证众望所归。(239字)
84 0
|
6天前
|
人工智能 自然语言处理 语音技术
蔓藤AI-一站式数字人创作平台-官网全新改版升级
蔓藤AI官网全新升级!首页改为任务导向,聚焦“我想做什么”;定位一站式数字人创作平台,支持文案生成视频、图片开口说话、声音克隆/转换、PPT讲解、动画视频及实时互动等能力,零门槛赋能营销、培训、客服等场景。(239字)
67 0
|
6天前
|
编解码 数据挖掘 BI
再没有比这更方便好用的数据分析工具了
这是一款免安装、免打开Excel的在线数据分析工具:上传CSV/Excel文件,直接对话提问,秒得统计结果与数据洞察;还能一键生成讲解视频,支持自定义画面、配音等。操作极简,洞察高效!
52 0
|
6天前
|
自然语言处理 语音技术 开发工具
一套开源镜头卡库的实战笔记:我们怎么把 video-shotcraft 接进动效短片
本文揭秘如何将开源动效库 video-shotcraft 深度集成进自研动效短片系统:不套模板、不手绘帧,而是通过移植35张“配方卡”+可参数化镜头组件,实现信息清晰表达与电影感运镜的统一。详述四天攻坚五大技术坑(零Props契约、帧精度时长约束、音频授权风险等),并阐明其与“一键成片”的本质差异——数据真实、画面可控、审美可维护。(239字)
58 0