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

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

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


一、上传文件示例

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

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

代码如下:

ini 复制代码
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,这样这个文件就不会直接发送给大模型,进而避免报错。

ini 复制代码
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 写法:

python 复制代码
"""
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 常常会不调用工具就直接返回「文件没上传」。

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

- ✅ 必须立即调用 `save_pdf_to_directory` 工具
- ✅ 工具会自动检测是否有上传的文件
- ✅ 如果有文件,工具会保存并返回结果
- ✅ 如果没有文件,工具会返回错误消息
- ❌ 绝对不要在调用工具前就说 "没有检测到文件"
- ❌ 绝对不要要求用户 "上传文件" 而不先调用工具
相关推荐
调试人生的显微镜4 小时前
苹果 App 怎么上架?从开发到发布的完整流程与使用 开心上架 跨平台上传
后端
顾漂亮4 小时前
Spring AOP 实战案例+避坑指南
java·后端·spring
间彧4 小时前
Redis Stream相比阻塞列表和发布订阅有哪些优势?适合什么场景?
后端
间彧4 小时前
Redis阻塞弹出和发布订阅模式有什么区别?各自适合什么场景?
后端
苏三说技术4 小时前
统计接口耗时的6种常见方法
后端
SimonKing4 小时前
Mybatis-Plus的竞争对手来了,试试 MyBatis-Flex
java·后端·程序员
我命由我123455 小时前
PDFBox - PDFBox 加载 PDF 异常清单(数据为 null、数据为空、数据异常、文件为 null、文件不存在、文件异常)
java·服务器·后端·java-ee·pdf·intellij-idea·intellij idea
渣哥5 小时前
当容器里有多个 Bean,@Qualifier 如何精准定位?
javascript·后端·面试
7哥♡ۣۖᝰꫛꫀꪝۣℋ5 小时前
Spring Boot
java·spring boot·后端