多模态 AI Agent 的 Rust SDK 设计:文本、图像与代码的统一处理

cover

一、多模态 Agent 的工程挑战:三种输入,三套处理逻辑

早期的 AI Agent 只处理文本——用户发一句话,Agent 回复一段文字。但实际业务中,用户可能上传一张截图问"这个报错怎么修",或者贴一段代码问"这里有什么 Bug"。多模态 Agent 需要同时处理文本、图像和代码三种输入,每种输入的预处理、编码和推理逻辑完全不同。

文本输入需要分词(Tokenization)和嵌入(Embedding);图像输入需要缩放、归一化和视觉编码(Vision Encoding);代码输入需要语法解析和结构化表示。如果每种输入都写一套独立的处理逻辑,Agent 的代码会变成三个互不关联的模块——新增一种模态就要重写一遍集成逻辑。

Rust SDK 的设计目标是:用统一的接口抽象多模态输入,让 Agent 的核心逻辑不关心输入是文本、图像还是代码。具体做法是定义 Modality trait,每种模态实现自己的编码逻辑,Agent 通过 trait object 统一调用。

二、多模态 Agent SDK 的架构设计

2.1 分层架构

SDK 分为三层:输入层(Input)、编码层(Encoding)和推理层(Inference)。

flowchart TD
    A[用户输入] --> B{模态识别}
    B -->|文本| C[TextInput]
    B -->|图像| D[ImageInput]
    B -->|代码| E[CodeInput]

    C --> F[Modality trait]
    D --> F
    E --> F

    F --> G[encode → Embedding]
    G --> H[多模态融合]
    H --> I[LLM 推理]
    I --> J[输出解析]
    J --> K[Agent 响应]

    subgraph 编码层
        L[TextEncoder: Tokenize + Embed]
        M[ImageEncoder: Resize + Vision Encode]
        N[CodeEncoder: Parse + Structure Encode]
    end

    subgraph 推理层
        O[Prompt 组装]
        P[API 调用]
        Q[流式响应处理]
    end

2.2 Modality Trait 设计

Modality trait 是整个 SDK 的核心抽象。每种模态需要实现两个方法:

  • encode():将原始输入转换为 LLM 可以理解的格式(文本 prompt 或 embedding 向量)。
  • to_prompt_segment():将编码结果转换为 prompt 片段,拼接到最终的 LLM 请求中。

2.3 多模态融合策略

多模态融合有两种策略:

  • 早期融合:在编码阶段将不同模态的 embedding 拼接为一个向量,输入 LLM。需要多模态模型(如 GPT-4V)支持。
  • 晚期融合:各模态独立编码,在 prompt 层面拼接为文本描述,输入纯文本 LLM。兼容性更好,但信息损失较大。

SDK 默认采用晚期融合策略(兼容纯文本 LLM),同时提供早期融合的接口供多模态模型使用。

三、Rust 生产级代码实现

3.1 Modality Trait 与模态实现

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

/// 模态类型标识
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ModalityType {
    Text,
    Image { format: String },
    Code { language: String },
}

/// 编码结果
#[derive(Debug, Clone)]
pub enum EncodedInput {
    /// 文本 prompt 片段
    TextSegment(String),
    /// Base64 编码的图像数据(用于多模态 API)
    ImageBase64 { data: String, media_type: String },
    /// 结构化代码表示
    CodeBlock { language: String, content: String },
}

/// 模态 trait:所有输入类型必须实现
#[async_trait]
pub trait Modality: Send + Sync {
    /// 模态类型标识
    fn modality_type(&self) -> ModalityType;

    /// 编码输入
    async fn encode(&self) -> Result<EncodedInput, ModalityError>;

    /// 转换为 prompt 片段
    fn to_prompt_segment(&self, encoded: &EncodedInput) -> String;
}

/// 模态处理错误
#[derive(Debug, thiserror::Error)]
pub enum ModalityError {
    #[error("图像处理失败: {0}")]
    ImageProcessing(String),

    #[error("代码解析失败: {0}")]
    CodeParsing(String),

    #[error("编码失败: {0}")]
    Encoding(String),

    #[error("IO 错误: {0}")]
    Io(#[from] std::io::Error),
}

/// 文本输入
pub struct TextInput {
    pub content: String,
}

#[async_trait]
impl Modality for TextInput {
    fn modality_type(&self) -> ModalityType {
        ModalityType::Text
    }

    async fn encode(&self) -> Result<EncodedInput, ModalityError> {
        Ok(EncodedInput::TextSegment(self.content.clone()))
    }

    fn to_prompt_segment(&self, encoded: &EncodedInput) -> String {
        match encoded {
            EncodedInput::TextSegment(text) => text.clone(),
            _ => unreachable!(),
        }
    }
}

/// 图像输入
pub struct ImageInput {
    pub path: PathBuf,
    pub description: Option<String>,
}

#[async_trait]
impl Modality for ImageInput {
    fn modality_type(&self) -> ModalityType {
        let ext = self.path.extension()
            .and_then(|e| e.to_str())
            .unwrap_or("png")
            .to_string();
        ModalityType::Image { format: ext }
    }

    async fn encode(&self) -> Result<EncodedInput, ModalityError> {
        let data = tokio::fs::read(&self.path).await?;
        let base64_data = base64::engine::general_purpose::STANDARD.encode(&data);

        let media_type = match self.path.extension()
            .and_then(|e| e.to_str()) {
            Some("jpg") | Some("jpeg") => "image/jpeg",
            Some("png") => "image/png",
            Some("gif") => "image/gif",
            Some("webp") => "image/webp",
            _ => "image/png",
        };

        Ok(EncodedInput::ImageBase64 {
            data: base64_data,
            media_type: media_type.to_string(),
        })
    }

    fn to_prompt_segment(&self, encoded: &EncodedInput) -> String {
        let desc = self.description.as_deref().unwrap_or("图片");
        match encoded {
            EncodedInput::ImageBase64 { .. } => {
                format!("[用户上传了一张{},请分析图片内容]", desc)
            }
            _ => unreachable!(),
        }
    }
}

/// 代码输入
pub struct CodeInput {
    pub content: String,
    pub language: String,
    pub context: Option<String>,
}

#[async_trait]
impl Modality for CodeInput {
    fn modality_type(&self) -> ModalityType {
        ModalityType::Code {
            language: self.language.clone(),
        }
    }

    async fn encode(&self) -> Result<EncodedInput, ModalityError> {
        Ok(EncodedInput::CodeBlock {
            language: self.language.clone(),
            content: self.content.clone(),
        })
    }

    fn to_prompt_segment(&self, encoded: &EncodedInput) -> String {
        match encoded {
            EncodedInput::CodeBlock { language, content } => {
                let context = self.context.as_deref().unwrap_or("");
                format!(
                    "```{}\n{}\n```\n{}",
                    language, content, context
                )
            }
            _ => unreachable!(),
        }
    }
}

3.2 Agent 请求构建器

/// Agent 请求
pub struct AgentRequest {
    inputs: Vec<Box<dyn Modality>>,
    system_prompt: Option<String>,
    max_tokens: Option<u32>,
    temperature: Option<f32>,
}

impl AgentRequest {
    pub fn new() -> Self {
        Self {
            inputs: Vec::new(),
            system_prompt: None,
            max_tokens: None,
            temperature: None,
        }
    }

    /// 添加模态输入
    pub fn with_input(mut self, input: impl Modality + 'static) -> Self {
        self.inputs.push(Box::new(input));
        self
    }

    pub fn system_prompt(mut self, prompt: &str) -> Self {
        self.system_prompt = Some(prompt.to_string());
        self
    }

    pub fn max_tokens(mut self, tokens: u32) -> Self {
        self.max_tokens = Some(tokens);
        self
    }

    pub fn temperature(mut self, temp: f32) -> Self {
        self.temperature = Some(temp);
        self
    }

    /// 构建最终的 LLM 请求
    pub async fn build(self) -> Result<LlmRequest, ModalityError> {
        let mut prompt_parts = Vec::new();
        let mut images = Vec::new();

        for input in &self.inputs {
            let encoded = input.encode().await?;
            let segment = input.to_prompt_segment(&encoded);
            prompt_parts.push(segment);

            // 收集图像数据(用于多模态 API)
            if let EncodedInput::ImageBase64 { data, media_type } = &encoded {
                images.push(ImageData {
                    data: data.clone(),
                    media_type: media_type.clone(),
                });
            }
        }

        let user_prompt = prompt_parts.join("\n\n");

        Ok(LlmRequest {
            system_prompt: self.system_prompt,
            user_prompt,
            images,
            max_tokens: self.max_tokens,
            temperature: self.temperature,
        })
    }
}

/// LLM 请求数据
pub struct LlmRequest {
    pub system_prompt: Option<String>,
    pub user_prompt: String,
    pub images: Vec<ImageData>,
    pub max_tokens: Option<u32>,
    pub temperature: Option<f32>,
}

pub struct ImageData {
    pub data: String,
    pub media_type: String,
}

3.3 Agent 运行时

use reqwest::Client;
use serde_json::json;

/// Agent 运行时
pub struct AgentRuntime {
    client: Client,
    api_base: String,
    api_key: String,
    model: String,
}

impl AgentRuntime {
    pub fn new(api_key: &str, model: &str) -> Self {
        Self {
            client: Client::new(),
            api_base: "https://api.openai.com/v1".to_string(),
            api_key: api_key.to_string(),
            model: model.to_string(),
        }
    }

    /// 执行 Agent 请求
    pub async fn run(&self, request: AgentRequest) -> Result<String, Box<dyn std::error::Error>> {
        let llm_request = request.build().await?;

        // 构建消息列表
        let mut messages = Vec::new();

        if let Some(system) = llm_request.system_prompt {
            messages.push(json!({
                "role": "system",
                "content": system,
            }));
        }

        // 如果有图像,使用多模态消息格式
        if llm_request.images.is_empty() {
            messages.push(json!({
                "role": "user",
                "content": llm_request.user_prompt,
            }));
        } else {
            let mut content = vec![
                json!({ "type": "text", "text": llm_request.user_prompt }),
            ];
            for img in llm_request.images {
                content.push(json!({
                    "type": "image_url",
                    "image_url": {
                        "url": format!("data:{};base64,{}", img.media_type, img.data),
                    }
                }));
            }
            messages.push(json!({
                "role": "user",
                "content": content,
            }));
        }

        let mut body = json!({
            "model": self.model,
            "messages": messages,
        });

        if let Some(max_tokens) = llm_request.max_tokens {
            body["max_tokens"] = json!(max_tokens);
        }
        if let Some(temp) = llm_request.temperature {
            body["temperature"] = json!(temp);
        }

        let response = self.client
            .post(format!("{}/chat/completions", self.api_base))
            .header("Authorization", format!("Bearer {}", self.api_key))
            .json(&body)
            .send()
            .await?;

        let result: serde_json::Value = response.json().await?;
        let content = result["choices"][0]["message"]["content"]
            .as_str()
            .unwrap_or("")
            .to_string();

        Ok(content)
    }
}

四、Trade-offs:多模态 SDK 的设计取舍

4.1 晚期融合的信息损失

晚期融合将图像和代码转换为文本描述,会丢失部分信息。例如,图像中的布局关系和颜色细节很难用文字精确描述。如果业务需要精确的图像理解,应该使用早期融合 + 多模态模型。SDK 的设计允许两种策略共存——encode() 方法返回的 EncodedInput 可以是 embedding 向量(早期融合)或文本描述(晚期融合)。

4.2 trait object 的运行时开销

Vec<Box<dyn Modality>> 使用动态分发,每次调用 encode()to_prompt_segment() 都需要虚表查找。在模态数量少(通常 <10)的场景下,这个开销可以忽略。但如果需要处理大量输入(如批量图像分析),可以考虑用枚举替代 trait object,获得静态分发的性能优势。

4.3 适用边界

多模态 Agent SDK 适用于以下场景:Agent 需要处理多种输入类型、输入类型在运行时才能确定、需要兼容不同 LLM API(纯文本和多模态)。不适用于:只处理单一模态(不需要抽象层)、对性能要求极高(trait object 的间接调用开销不可接受)、输入类型固定且已知(用枚举更简洁)。

五、总结

多模态 Agent SDK 的核心是 Modality trait——用统一的接口抽象不同输入类型的编码逻辑。核心落地步骤如下:

  1. 定义 Modality trait:统一 encode()to_prompt_segment() 接口,各模态独立实现。
  2. 实现三种模态:TextInput(直接传递)、ImageInput(Base64 编码)、CodeInput(代码块格式化)。
  3. 构建 Agent 请求:通过 Builder 模式组合多种模态输入,自动拼接 prompt。
  4. 兼容多模态 API:图像数据以 image_url 格式传递,纯文本 LLM 自动降级为文本描述。
  5. 扩展新模态:只需实现 Modality trait,不需要修改 Agent 核心逻辑。

抽象的目标不是消除差异,而是让差异不影响核心逻辑。多模态 Agent SDK 让"处理什么输入"和"如何推理"解耦,新增一种模态只需要加一个 trait 实现。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐