[AI] SendArtifacts:Agent 如何把结果准确交付给用户 | SendArtifacts: How AI Agents Deliver Results to Users

Agent 能干活是基础,把结果准确交到用户手上才是闭环。


目录


Agent 的最后一公里问题

前面几篇讲了 Agent 怎么搜索、怎么抓取、怎么解析文件——都是"输入侧"。但 Agent 的价值链不止于"我能做",还有一个经常被忽略的环节:做完之后怎么交到用户手上

用户:"帮我把这个视频切成 10 个短视频"
        │
        ▼
    Agent 在沙箱里跑 ffmpeg
        │
        ▼
    10 个 .mp4 文件躺在 /data/agent/alice/output/ 里
        │
        ▼
    然后呢?

"然后呢"这个问题比想象中复杂。Agent 的工作环境是沙箱——文件存在沙箱的虚拟文件系统里,用户看不到。Agent 需要一种机制把产物"递"出来,而且这个"递"必须满足几个条件:

  1. 用户能看到——不是一串路径字符串,是可预览、可下载的卡片
  2. IM 渠道也能收到——如果用户在 Telegram/Discord/飞书和 Agent 对话,文件要能推到对应的 IM
  3. 部分失败不影响整体——10 个文件里有 1 个路径写错了,其他 9 个仍然要交付
  4. LLM 不需要关心交付细节——它只需要说"这些文件交给你了"

这就是 SendArtifacts 要解决的问题。


Image

SendArtifacts 的工具定义

工具本身定义得很克制:

tool!(
    "SendArtifacts",
    "Deliver final result files to the user.",
    "Deliver final result files to the user as the LAST step of a session.
     Accepts a single file object or an array.",
    json!({
        "type": "object",
        "required": ["files"],
        "properties": {
            "files": {
                "oneOf": [
                    { "type": "array", "minItems": 1,
                      "items": { "$ref": "#/$defs/file" } },
                    { "$ref": "#/$defs/file" }
                ]
            }
        },
        "$defs": {
            "file": {
                "type": "object",
                "required": ["path"],
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "Absolute sandbox path under
                          /data/agent/<sanitized>/{workspace,output,tmp,upload}/.
                          Move the file there first if needed."
                    }
                }
            }
        }
    }),
    false,
    ToolCategory::Core,
    None, None,
    ConcurrencyMode::Never    // ← 终态工具,不允许并发
)

几个值得说的点:

files 支持单文件或数组——用 JSON Schema 的 oneOf。LLM 交付一个文件时不需要包数组,直接传 {"path": "..."} 就行。降低出错概率。

ConcurrencyMode::Never——SendArtifacts 是会话的终态。一旦调用,意味着 Agent 认为任务完成。不允许它和其他工具并发执行,避免"还在跑命令就先交付了半成品"的竞态。

路径描述里写死了四个允许的子目录——workspaceoutputtmpupload。这不是随便选的,是沙箱 AgentPaths 系统定义的标准目录。Agent 必须先把文件移到这些目录之一,才能交付。这个约束在工具描述里就告诉 LLM,而不是等后端报错。


后端实现:五层校验,逐文件容错

SendArtifactsTool::invoke 的核心逻辑是一个对每个文件的校验流水线。先看整体流程:

graph TD
    A["LLM 调用 SendArtifacts"] --> B["解析 args.files"]
    B --> C{"是 JSON 字符串?"}
    C -->|是| D["反序列化"]
    C -->|否| E["直接用"]
    D --> F["遍历每个 file"]
    E --> F
    F --> G{"path 非空?"}
    G -->|否| H["记录失败,continue"]
    G -->|是| I{"host_of 解析成功?"}
    I -->|否| H
    I -->|是| J{"在允许的前缀下?"}
    J -->|否| H
    J -->|是| K{"文件存在且非空?"}
    K -->|否| H
    K -->|是| L["构建 metadata entry"]
    L --> M["汇总 ToolOutput"]
    H --> M

双编码容忍:LLM 的 JSON 噪音

第一层校验之前有一个不显眼但关键的步骤——双编码容忍

let files_value: Value = match files_raw {
    Value::String(s) => match serde_json::from_str::<Value>(s) {
        Ok(parsed @ (Value::Array(_) | Value::Object(_))) => parsed,
        _ => { return ToolOutput::err("..."); }
    },
    other => other.clone(),
};

这不是假设,是实战经验。某些 LLM 提供商会把嵌套的 JSON 参数二次编码成字符串——你期望收到 {"files": [{...}]},实际收到的是 {"files": "[{...}]"}。一个字符串,不是数组。

如果这里不做容忍处理,所有来自这些提供商的 SendArtifacts 调用都会失败。而且错误信息对 LLM 来说毫无帮助——它不知道自己的输出被中间层扭曲了。

路径安全:限定可交付目录

路径拦截的核心目的很简单:安全可控 + 精准交付

沙箱里有多个子目录——代码、配置、运行时状态、输出文件各归其位。Agent 只能从 workspaceoutputtmpupload 这四个目录交付文件,其他目录一律拒绝。这确保了两件事:

  1. 安全——Agent 不能把沙箱的代码或配置文件泄露给用户
  2. 精准——前端和 IM 渠道拿到的路径一定是合法的可交付路径,不需要再做额外校验

每个文件的路径都会经过一次翻译(沙箱内路径 → 宿主机实际路径),翻译失败或不在允许目录下的直接跳过,不影响其他文件。

逐文件校验流水线

每个文件独立校验,失败的记录错误但不中断整个批次。校验的核心是:路径是否在允许的目录内、文件是否存在且可读。

逐文件容错的设计意图:LLM 经常在批量操作时犯部分错误。如果 10 个文件里有 1 个路径写错了,用户不应该收到"交付失败"——应该收到"交付了 9 个,1 个失败(原因:xxx)"。

// 每个文件的结果独立记录
artifacts.push(json!({
    "ok": false,
    "path": path_str,
    "error": "file not found or unreadable: No such file or directory",
}));
// 不 return,继续处理下一个

最终的 ToolOutput 同时包含人类可读的摘要和结构化的 metadata:

Delivered 9 of 10 artifacts.
  ✓ report.pdf (1.2 MB) — /data/agent/alice/output/report.pdf
  ✓ chart.png (340 KB) — /data/agent/alice/workspace/chart.png
  ...
  ✗ missing.txt — file not found or unreadable: No such file or directory

metadata 与 output 分离

在讲实现之前,先聊一个贯穿整个设计的核心决策:ToolOutputmetadata 字段

pub struct ToolOutput {
    pub success: bool,
    pub output: String,              // ← LLM 看到的
    pub image_urls: Vec<String>,
    pub followup_user: Option<String>,
    pub metadata: Option<Value>,     // ← LLM 看不到,只有前端和渠道看
}

output 是返回给 LLM 的文本——模型会把它放进 context,继续推理。metadata 是持久化到数据库、推送给前端和 IM 渠道的结构化数据——LLM 永远不会看到它

为什么要做这个分离?

┌───────────────────────────────────────────────────────┐
│                    ToolOutput                         │
│                                                       │
│  output: "Delivered 2 of 2 artifacts.\n              │
│    ✓ report.pdf (1.2 MB)\n                           │
│    ✓ chart.png (340 KB)"                             │
│                                                       │
│  metadata: {                                          │
│    "artifacts": [                                     │
│      { "ok": true, "agentId": "abc",                 │
│        "path": "/host/.../report.pdf",               │
│        "name": "report.pdf", "size": 1258291,        │
│        "mime": "application/pdf" },                   │
│      { "ok": true, "agentId": "abc",                 │
│        "path": "/host/.../chart.png",                │
│        "name": "chart.png", "size": 348160,          │
│        "mime": "image/png" }                          │
│    ]                                                  │
│  }                                                    │
└───────────────────────────────────────────────────────┘
         │                          │
         ▼                          ▼
    发给 LLM                   持久化到 DB
    (人类可读的摘要)            推送给前端 / IM 渠道
    (不含敏感路径)              (含 host 路径、agentId、MIME)

LLM 需要的是人类可读的摘要——"交了几个、成功几个、文件名是什么"。它不需要知道 host 绝对路径、agentId、MIME type。把这些塞进 output 只会浪费 token、引入噪音。

前端和渠道需要的是结构化数据——path 用来构建下载 URL,mime 用来决定渲染方式,agentId 用来定位 API 端点。

这个分离让每一层只拿到它需要的信息。不是什么高深的架构模式,但很多 Agent 框架没做这一步——它们把所有信息都塞进 output text,让前端自己从 LLM 的自然语言输出里 parse 文件路径。那是一条通往灾难的路。


双通道交付:Web 渲染 + IM 转发

SendArtifacts 最有意思的设计不是工具本身,而是它触发的两条独立的交付通道。同一个工具调用,同时驱动前端渲染和 IM 转发,两条路互不干扰。

graph LR
    A["SendArtifacts.invoke()"] --> B["ToolOutput { output, metadata }"]
    B --> C["持久化到 DB"]
    C --> D["ChatProjector 发射 AppEvent"]
    D --> E["前端:SSE 推送 + ToolCallCard 渲染"]
    D --> F["channel_outbound:IM 文件转发"]

    E --> G["Web 用户看到卡片"]
    F --> H["IM 用户收到文件"]

事件总线:AppEvent::AiToolResult

ChatProjector 是连接工具执行和交付通道的桥梁。当工具结果被持久化到数据库时,它向事件总线广播一个事件:

AppEvent::AiToolResult {
    conv_id: Uuid,          // 哪个会话
    tool_use_id: String,    // 哪次工具调用
    tool_name: String,      // "SendArtifacts"
    metadata: Option<Value>, // 完整的 metadata blob
}

这个事件有两个订阅者:

  1. 前端的 SSE 推送——通过 ConversationJournal 把消息实时推给浏览器
  2. channel_outbound 转发器——检查这个会话是否绑定了 IM 渠道,如果是,把文件推过去

通道一:前端渲染器

前端的渲染路径是:

DB 持久化的消息
    ↓ message-to-blocks.ts
RenderBlock[](包含 ToolOutput 的 metadata)
    ↓ ToolCallCard.tsx
查找 TOOL_RENDERERS["SendArtifacts"]
    ↓
SendArtifactsRenderer(fullRow = true)
    ↓
渲染为全宽卡片网格

通道二:IM 渠道转发

channel_outbound::spawn 启动一个后台任务,订阅事件总线:

pub fn spawn(state: Arc<AppState>) {
    tokio::spawn(async move {
        let mut rx = state.event_tx.subscribe();
        loop {
            match rx.recv().await {
                Ok(AppEvent::AiToolResult { tool_name, metadata, conv_id, .. }) => {
                    if tool_name != "SendArtifacts" { continue; }
                    // ... 转发到 IM 渠道
                }
            }
        }
    });
}

收到事件后的逻辑:

  1. 反查渠道绑定——通过 ChannelUserConversationRepo::find_for_conv 查这个会话是否绑定了 IM 渠道。纯 Web 聊天没有绑定,直接跳过
  2. 逐文件读取 + 发送——对每个成功的 artifact,tokio::fs::read 读取 host 文件,构建 FilePayload::Bytes,调用 channel_hub.reply_file_to_user
  3. 逐文件错误隔离——单个文件读取失败或发送失败,warn 日志然后跳过,不影响其他文件
for art in artifacts {
    if !art.get("ok").and_then(Value::as_bool).unwrap_or(false) {
        continue;  // 跳过失败的
    }
    let data = match tokio::fs::read(host_path).await {
        Ok(d) => d,
        Err(e) => { warn!(...); continue; }  // 读失败就跳过
    };
    let payload = FilePayload::Bytes { data: Bytes::from(data), filename, content_type };
    match state.channel_hub.reply_file_to_user(...).await {
        Ok(()) => { info!(...); }
        Err(e) => { warn!(...); }  // 发失败就跳过
    }
}

关键设计:两条通道完全解耦。 前端渲染不依赖 IM 转发,IM 转发不影响前端渲染。工具执行的结果(ToolOutput)是唯一的数据源。IM 转发是 best-effort——失败了就失败了,用户在 Web 端仍然能看到卡片。


前端可视化:从 metadata 到卡片

前端的 SendArtifactsRenderer 是整个交付链的最后一环——把结构化的 metadata 变成用户看到的东西。

fullRow:打破 ToolCallCard 的默认布局

Tokimo 的工具渲染系统有两种布局模式:

模式 行为 适用场景
默认 可折叠的 action row Bash、Read、Grep 等过程性工具
fullRow = true 全宽渲染,不可折叠 SendArtifacts 等需要展示丰富 UI 的工具
export type ToolRenderer = React.FC<ToolRendererProps> & {
  fullRow?: boolean;
};

// SendArtifacts 是少数设置 fullRow 的工具
SendArtifactsRenderer.fullRow = true;

为什么 SendArtifacts 需要 fullRow?因为文件卡片需要横向排列——一行一个文件太浪费空间,响应式网格(grid-cols-1 sm:grid-cols-2 lg:grid-cols-3)需要全宽。如果塞进默认的可折叠 action row 里,布局会很局促。

图片 vs 文件:两条渲染路径

拿到 artifacts 数组后,每个 item 根据类型走不同的渲染路径:

graph TD
    A["ArtifactRow"] --> B{artifact.ok?}
    B -->|否| C["AlertCircle + 错误信息"]
    B -->|是| D{isImageArtifact?}
    D -->|是| E["ImageArtifactRow"]
    D -->|否| F["FileArtifactRow"]

    E --> G["36x36 缩略图"]
    E --> H["hover 弹出大图预览"]
    E --> I["点击打开应用内查看器"]

    F --> J["MaterialFileIcon"]
    F --> K["文件名 + 大小"]
    F --> I

图片检测逻辑很朴素:

function isImageArtifact(a: OkArtifact): boolean {
    if (a.mime?.startsWith("image/")) return true;
    const ext = a.name.split(".").pop()?.toLowerCase();
    return ext !== undefined && IMAGE_EXTS.has(ext);
}

先看 MIME(后端通过 mime_guess 推断),再看扩展名。两条路径兜底——MIME 缺失时靠扩展名,扩展名不常见时靠 MIME。

图片文件的交互比普通文件丰富一些:

  • 缩略图——36x36 的小图,直接在卡片行内展示
  • hover 预览——鼠标悬停时弹出 Popover,展示最大 320px 的预览图
  • 点击查看——调用 windowManager.openWindow 打开应用内的图片查看器

普通文件只有 MaterialFileIcon + 文件名 + 大小,但同样支持点击打开和 hover 时出现的下载按钮。

两种类型共享同一个交互模式:hover 显示下载按钮

<button
    className={cn(
        "absolute top-1/2 right-2 ...",
        "opacity-0 transition-opacity",
        "group-hover:opacity-100 ...",
    )}
>
    <Download className="h-3.5 w-3.5" />
</button>

下载按钮用 absolute 定位在卡片右上角,默认 opacity-0group-hoveropacity-100。不占空间,不干扰主内容,需要时自然出现。

三态设计:loading / placeholder / done

渲染器处理三种状态:

状态 条件 展示
Loading status === "running" 且无 metadata 旋转图标 + "正在交付文件…"
Placeholder 无 metadata 但 args 里有文件列表 从 args 解析文件名,展示占位卡片
Done metadata 可用 完整的文件卡片网格

Placeholder 状态是一个有意思的细节。当工具正在执行时,前端已经从 args.files 知道了要交付哪些文件——虽然还没有 size、mime、host path,但至少能展示文件名和图标。这让用户在工具执行过程中就能看到"正在交付这些文件"的视觉反馈,而不是一片空白。

if (!artifacts || artifacts.length === 0) {
    if (status === "running") {
        return <LoadingSpinner />;
    }
    // 从 args 里提取文件名做占位
    const rawList = Array.isArray(args.files) ? args.files : null;
    if (rawList && rawList.length > 0) {
        return <PlaceholderGrid items={rawList} />;
    }
}

设计取舍

几个在设计过程中讨论过的取舍:

为什么不让 LLM 直接输出文件路径给前端 parse?

因为 LLM 的输出是非结构化的自然语言。它可能说"文件在 /data/agent/alice/output/report.pdf",也可能说"我已经把报告生成好了",也可能把路径拼错。让前端从自然语言里 extract 文件路径,本质上是在做 NLP——用正则还是用另一个 LLM?两条路都不靠谱。

metadata 是确定性的结构化数据。后端校验通过的文件,metadata 里的 path、size、mime 一定是正确的。前端不需要猜测。

为什么 channel_outbound 是 best-effort 而不是事务性的?

因为 IM 渠道的可用性不可控。Telegram API 可能限流,Discord 可能宕机,飞书可能超时。如果把 IM 发送放进工具执行的关键路径里,一个渠道的抖动会导致整个工具调用超时或失败。

工具的结果(ToolOutput)是确定性的——后端校验完了就知道成功还是失败。IM 转发是尽力而为的——成功了用户在 IM 里也能看到,失败了用户至少在 Web 端能看到。

为什么前端用 host path 而不是 guest path?

因为前端要通过 HTTP API 访问文件——/api/apps/ai/agents/{id}/files/read?path=...。这个 API 跑在宿主机上,需要 host path。如果给它 guest path,还要再做一次翻译。

而且 host path 是后端经过 canonicalize() 的绝对路径,不存在歧义。Guest path 可能有符号链接、可能有 ..,需要额外的清理。


未来展望

更丰富的 artifact 类型

目前 SendArtifacts 只处理文件。但 Agent 的产出不只是文件——它可能生成一段代码、一个 URL、一条结构化数据。未来的扩展方向:

{
    "artifacts": [
        { "type": "file", "path": "...", "name": "report.pdf" },
        { "type": "url", "url": "https://...", "title": "在线报告" },
        { "type": "code", "language": "python", "content": "..." },
        { "type": "data", "format": "csv", "preview": "col1,col2\n..." }
    ]
}

每种类型对应不同的前端渲染器——URL 可以预览,代码可以高亮,数据可以展示表格预览。

artifact 流式交付

当前模型是"工具执行完毕 → 一次性交付所有文件"。但有些场景下,Agent 会生成多个文件,每个文件的生成时间不同。如果能边生成边交付——第一个文件生成完就立刻推给用户——体验会更好。

这需要把 SendArtifacts 从"终态一次性调用"变成"可多次调用的增量交付"。技术上不难,但要处理好前端的增量更新和 IM 渠道的多次推送。

用户反馈回路

当前的交付是单向的——Agent 交付,用户接收。但用户可能想对 artifact 做反馈:"这个图表的颜色不对"、"报告里第三段要改"。如果能把用户的反馈(包括对具体 artifact 的标注)回传给 Agent,就能形成一个闭环的迭代工作流。


这是 Tokimo AI Agent 工具链系列的第五篇。前四篇:Bash is all you needWeb SearchWeb FetchFile Parser

暂无评论,去 GitHub 留下第一条评论吧