Agno Agent 服务端文件上传处理机制

146 阅读6分钟

最近实习在做 AI Agent 的时候,出现了一个上传文件的场景:你要上传一个文件,让 AI 帮你处理这个文件。

那么在这个场景下,我生出来一个疑问:文件是怎么上传的?


一、上传文件示例

首先,我们来看这样一个例子:

我要对 agent 进行测试,所以我手动构造了一个模拟的“上传文件对象”。

代码如下:

pdf_file = File(
    content=pdf_content,
    filename=pdf_info['filename'],
    content_type='application/pdf'
)

response = self.agent.run(
    prompt, 
    files=[pdf_file],  # 正确的文件上传方式
    stream=False
)

我们可以看到,构造的文件被上传了,我们的 Agent 也认识了我们的文件。

这类对象的本质,就是模仿浏览器发送给后端的文件结构。 简单了解常见的文件


二、Agno 服务端如何处理上传的文件

那么浏览器发送给后端,我们后端的 Agno 是如何处理这个文件的?

Agno 在服务端使用 FastAPI 来提供 REST 接口。
通过 AgentOS.get_app() 会得到一个内置的 FastAPI 应用。

默认情况下,发送给 Agent 的请求使用 POST /agents/{agent_id}/runs 这个端点,该接口要求 multipart/form-data 格式,其中必须包含 message(文本消息)等字段,支持可选的 files 字段上传文件。
因此,上传接口由 FastAPI 定义,主要依赖 fastapi.UploadFile 类型来接收文件。


三、Agno Agent 如何识别并获取上传文件

在 FastAPI 收到文件后,框架会将它们封装为 UploadFile 对象(提供 .filename.content_type.file 等属性)。

Agno 的 AgentOS 运行时代码会根据文件的 MIME 类型进行分类处理:

  • 对于图像(PNG/JPEG/WebP 等)、音频(WAV/MP3 等)、视频(MP4/WebM 等)文件,分别转换为内部的 ImageAudioVideo 对象(通常是把文件流编码为 Base64 形式);
  • 对 PDF、CSV、DOCX、TXT、JSON 等文档类型,则转换为通用的 File 对象。

总之,Agent 通过检查上传文件的内容类型,并将其读入内存或临时文件,再创建相应的媒体对象进行后续处理。


四、Agno Agent 文件存储在哪里

FastAPI 的 UploadFile 默认使用内存与临时文件混合存储:

  • 小文件保存在内存;
  • 大文件超过阈值后会写入临时文件系统(SpooledTemporaryFile)。

总体来说,文件首先存在 FastAPI 提供的临时存储中,开发者也可以自行保存到指定目录后续使用。


五、文件处理及后续使用

上传后,文件通常有两种用途:

1. 传给模型

注意:一定是多模态模型,不然会报错!

如果模型支持多媒体输入(如多模态模型),可以直接将 Image / Audio 等对象的 Base64 内容发送给模型。

这依赖配置变量 send_media_to_model(默认为 True),表示是否将媒体内容附加到模型上下文。
如果设置为 False,则媒体文件不会传给模型,而是留待工具处理。

例如:我使用的 deepseek 不支持多模态,于是我将 send_media_to_model 设置为 False,这样这个文件就不会直接发送给大模型,进而避免报错。

pdf_translator_agent = Agent(
    name="pdf_translator_agent",
    model=TencentCloudDeepseek(
        id="deepseek-chat",
        base_url=config['api']['deepseek']['base_url'],
        thinking_enabled=False,
    ),
    tools=[
        DocumentProcessingTools()
    ]
    # 这个配置可以确保模型不会收到任何媒体文件,从而避免模型因为不支持 pdf 文件而报错
    send_media_to_model=False,
)

2. 传给 Tool 或解析

Agno 支持在 Agent 中使用自定义工具处理文件。

例如,可以编写一个 @tool 函数,参数为文件路径或 File 对象,然后在其中使用 Python 代码打开并解析该文件(如对 PDF 进行文本提取、对图片做 OCR)。

这里给出这种方式的一种 tools 写法:

"""
PDF文档处理工具模块

包含PDF翻译所需的所有工具类和辅助类
"""

from typing import Optional, Sequence
from agno.tools import Toolkit
from agno.media import File
import subprocess
import os
import uuid
from datetime import datetime


class DocumentProcessingTools(Toolkit):
    """PDF文档处理工具集"""
    
    def __init__(self):
        super().__init__(
            name="document_processing_tools",
            tools=[
                self.save_pdf_to_directory
            ]
        )
    
    def save_pdf_to_directory(
        self, 
        files: Optional[Sequence[File]] = None,
        target_directory: str = "/home/teams/pdf_translator/files"
    ) -> str:
        """
        将用户上传的 PDF 文件保存到指定目录
        
        这个工具可以访问用户上传的文件(自动注入),并将它们保存到文件系统中。
        用户上传的文件最初只存在于内存中,必须先保存到文件系统,pdf2zh 才能处理。
        
        Args:
            files: 用户上传的文件列表(自动注入)
            target_directory: 目标保存目录,默认为 /home/teams/pdf_translator/files
            
        Returns:
            保存结果信息,包括保存的文件列表和路径
            
        Example:
            用户上传 document.pdf
            → 自动保存到 /home/teams/pdf_translator/files/document.pdf
            → 返回保存成功的消息和完整路径
        """
        if not files:
            return "❌ 错误: 没有检测到上传的文件。请确保用户已上传 PDF 文件。"
        
        print(f"\n{'='*60}")
        print(f"💾 开始保存上传的文件")
        print(f"📂 目标目录: {target_directory}")
        print(f"📄 文件数量: {len(files)}")
        print(f"{'='*60}\n")
        
        # 确保目标目录存在
        os.makedirs(target_directory, exist_ok=True)
        
        saved_files = []
        errors = []
        
        for i, file in enumerate(files, 1):
            try:
                # 获取文件名
                filename = file.filename if hasattr(file, 'filename') and file.filename else f"uploaded_file_{i}.pdf"
                
                # 检查文件内容
                if not file.content:
                    error_msg = f"文件 {i} ({filename}): 内容为空"
                    print(f"⚠️  {error_msg}")
                    errors.append(error_msg)
                    continue
                
                # 构建完整路径
                file_path = os.path.join(target_directory, filename)
                
                # 获取文件大小
                file_size = len(file.content)
                file_size_mb = file_size / (1024 * 1024)
                
                print(f"📝 正在保存文件 {i}/{len(files)}:")
                print(f"   文件名: {filename}")
                print(f"   大小: {file_size_mb:.2f} MB ({file_size:,} bytes)")
                print(f"   路径: {file_path}")
                
                # 写入文件
                with open(file_path, 'wb') as f:
                    f.write(file.content)
                
                # 验证文件已保存
                if os.path.exists(file_path):
                    actual_size = os.path.getsize(file_path)
                    if actual_size == file_size:
                        print(f"   ✅ 保存成功!验证通过")
                        saved_files.append({
                            'filename': filename,
                            'path': file_path,
                            'size': file_size
                        })
                    else:
                        error_msg = f"文件 {filename}: 大小不匹配 (期望: {file_size}, 实际: {actual_size})"
                        print(f"   ❌ {error_msg}")
                        errors.append(error_msg)
                else:
                    error_msg = f"文件 {filename}: 保存后无法找到"
                    print(f"   ❌ {error_msg}")
                    errors.append(error_msg)
                
                print()
                
            except Exception as e:
                error_msg = f"文件 {i}: 保存失败 - {str(e)}"
                print(f"❌ {error_msg}")
                errors.append(error_msg)
                import traceback
                traceback.print_exc()
        
        # 生成返回消息
        print(f"{'='*60}")
        if saved_files:
            result_lines = [
                f"✅ 成功保存 {len(saved_files)} 个文件:\n"
            ]
            for file_info in saved_files:
                size_mb = file_info['size'] / (1024 * 1024)
                result_lines.append(
                    f"📄 {file_info['filename']}\n"
                    f"   路径: {file_info['path']}\n"
                    f"   大小: {size_mb:.2f} MB\n"
                )
            
            if errors:
                result_lines.append(f"\n⚠️ {len(errors)} 个文件失败:\n")
                for error in errors:
                    result_lines.append(f"   - {error}\n")
            
            result = "".join(result_lines)
            print(result)
            print(f"{'='*60}\n")
            return result
        else:
            error_result = f"❌ 所有文件保存失败\n\n错误详情:\n" + "\n".join(f"- {e}" for e in errors)
            print(error_result)
            print(f"{'='*60}\n")
            return error_result

六、提示词的注意事项

要注意的是,要配合提示词一起使用,不然 AI 常常会不调用工具就直接返回「文件没上传」。

*当用户说"翻译PDF"或提到文件时,不要假设没有文件!**

- ✅ 必须立即调用 `save_pdf_to_directory` 工具
- ✅ 工具会自动检测是否有上传的文件
- ✅ 如果有文件,工具会保存并返回结果
- ✅ 如果没有文件,工具会返回错误消息
- ❌ 绝对不要在调用工具前就说 "没有检测到文件"
- ❌ 绝对不要要求用户 "上传文件" 而不先调用工具