[AI] SendArtifacts:Agent 如何把结果准确交付给用户 | SendArtifacts: How AI Agents Deliver Results to Users
Agent 能干活是基础,把结果准确交到用户手上才是闭环。
目录
- Agent 的最后一公里问题
- 一个被忽略的设计决策:metadata 与 output 分离
- SendArtifacts 的工具定义
- 后端实现:五层校验,逐文件容错
- 双通道交付:Web 渲染 + IM 转发
- 前端可视化:从 metadata 到卡片
- 设计取舍
- 未来展望
Agent 的最后一公里问题
前面几篇讲了 Agent 怎么搜索、怎么抓取、怎么解析文件——都是"输入侧"。但 Agent 的价值链不止于"我能做",还有一个经常被忽略的环节:做完之后怎么交到用户手上。
用户:"帮我把这个视频切成 10 个短视频"
│
▼
Agent 在沙箱里跑 ffmpeg
│
▼
10 个 .mp4 文件躺在 /data/agent/alice/output/ 里
│
▼
然后呢?
"然后呢"这个问题比想象中复杂。Agent 的工作环境是沙箱——文件存在沙箱的虚拟文件系统里,用户看不到。Agent 需要一种机制把产物"递"出来,而且这个"递"必须满足几个条件:
- 用户能看到——不是一串路径字符串,是可预览、可下载的卡片
- IM 渠道也能收到——如果用户在 Telegram/Discord/飞书和 Agent 对话,文件要能推到对应的 IM
- 部分失败不影响整体——10 个文件里有 1 个路径写错了,其他 9 个仍然要交付
- LLM 不需要关心交付细节——它只需要说"这些文件交给你了"
这就是 SendArtifacts 要解决的问题。
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 认为任务完成。不允许它和其他工具并发执行,避免"还在跑命令就先交付了半成品"的竞态。
路径描述里写死了四个允许的子目录——workspace、output、tmp、upload。这不是随便选的,是沙箱 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 只能从 workspace、output、tmp、upload 这四个目录交付文件,其他目录一律拒绝。这确保了两件事:
- 安全——Agent 不能把沙箱的代码或配置文件泄露给用户
- 精准——前端和 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 分离
在讲实现之前,先聊一个贯穿整个设计的核心决策:ToolOutput 的 metadata 字段。
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
}
这个事件有两个订阅者:
- 前端的 SSE 推送——通过
ConversationJournal把消息实时推给浏览器 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 渠道
}
}
}
});
}
收到事件后的逻辑:
- 反查渠道绑定——通过
ChannelUserConversationRepo::find_for_conv查这个会话是否绑定了 IM 渠道。纯 Web 聊天没有绑定,直接跳过 - 逐文件读取 + 发送——对每个成功的 artifact,
tokio::fs::read读取 host 文件,构建FilePayload::Bytes,调用channel_hub.reply_file_to_user - 逐文件错误隔离——单个文件读取失败或发送失败,
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-0,group-hover 时 opacity-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 need、Web Search、Web Fetch、File Parser。
评论0
去 GitHub 评论 ↗暂无评论,去 GitHub 留下第一条评论吧