17.1 我们要交付什么(产品视角)

一个 AI 助手 App(跨 iOS/Android/桌面)的共享 Rust 核心,它负责三件事:

① 调用 LLM API 做流式问答      → network(第 13-15 课技能)
② 会话历史本地持久化(缓存)    → store(第 18 课 sqlite)
③ 把能力暴露给各端(UI 调用)   → api 层(第 19 课起 UniFFI 导出)

非目标(UI 壳做的事,不是 Rust 的活):
  登录 UI、输入框、气泡渲染、系统通知…… 由 Swift/Kotlin/TS 等壳实现

为什么这个分工合理?回顾第 1 课的定位:把"会变慢、要跨端共享、容易出 bug"的业务核心收进 Rust——LLM 调用、SSE 解析、历史存储、权限边界全是这类;而"长什么样"交给壳。

                    ┌──────────────────────────────────────┐
                    │        iOS / Android / Desktop 壳      │
                    │   (各端自己的 UI,不共享业务逻辑)      │
                    └───────────────┬──────────────────────┘
                                    │ UniFFI 生成的绑定(19-21 课)
                    ┌───────────────▼──────────────────────┐
                    │            my-ai-core (Rust)          │
                    │  ┌──────────┐ ┌──────────┐ ┌───────┐  │
                    │  │  api     │ │ network  │ │ store │  │
                    │  │ 领域服务  │→│ LLM/SSE  │→│ sqlite│  │
                    │  └──────────┘ └──────────┘ └───────┘  │
                    │  ┌──────────────────────────────┐     │
                    │  │  models:会话/消息/命令 领域模型 │     │
                    │  └──────────────────────────────┘     │
                    └──────────────────────────────────────┘

17.2 工程结构:workspace 起步

第 10 课预告过 workspace。实战篇从一开始就用,后续往里加 ffi-bindings、console-demo 都方便:

# 根 Cargo.toml
[workspace]
members = ["core", "ffi-bindings", "console-demo"]
resolver = "2"
voice_ai/
├── Cargo.toml             # workspace 根
├── core/                  # ★ 本课重点:共享核心(纯逻辑,可测试)
│   ├── Cargo.toml
│   └── src/
│       ├── lib.rs         # 对外 pub 出口
│       ├── models.rs      # 会话/消息/命令 领域模型
│       ├── llm/           # LLM 客户端:chat.rs(流式问答引擎)
│       ├── store.rs       # 存储 trait + sqlite 实现(第 18 课填实现)
│       └── service.rs     # 顶层领域服务:把 network/store 编排起来
├── ffi-bindings/          # 第 19 课:UniFFI 导出层(本课先建空壳)
├── console-demo/          # 第 17 课就做:无 UI 端到端验证
└── code/                  # (练习参考)
cargo new core --lib
cargo new console-demo --bin
cargo new ffi-bindings --lib
# 然后在根 Cargo.toml 填 workspace members,core 的 Cargo.toml 加依赖:
# serde / thiserror / reqwest / tokio / async-trait / rusqlite(第18课)
cargo build -p core        # 只构建 core

💡 为什么核心要独立成 纯 lib crate?——能被 console-demo、ffi-bindings、单元测试三种"宿主"同时引用;不依赖 UI、不依赖网络可达性。这个约束倒逼你写出"好测试的代码"。

17.3 领域模型:先定义"数据长什么样"

第 6 课学的 struct/enum 建模在这里发力。全部用拥有型数据 + #[derive](5.6 与 14.2 的纪律):

// models.rs
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Session {
    pub id: String,                 // UUID 之类
    pub title: String,
    pub created_at_ms: i64,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Role {
    User,
    Assistant,
    System,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Message {
    pub id: String,
    pub session_id: String,
    pub role: Role,
    pub content: String,
    pub created_at_ms: i64,
}

给模型配构造器与权限——禁止直接拼出非法对象:

impl Session {
    /// 唯一的创建入口:保证 title 非空、id 由内部生成
    pub fn new(title: impl Into<String>) -> Session {
        let title = title.into();
        Session {
            id: uuid_like_id(),
            title: if title.trim().is_empty() { "新会话".into() } else { title },
            created_at_ms: now_ms(),
        }
    }
}

pub fn uuid_like_id() -> String {
    // 演示:时间戳+随机;实战换 uuid crate
    format!("s-{}-{:04x}", now_ms(), rand16())
}

pub fn now_ms() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as i64)
        .unwrap_or(0)
}

fn rand16() -> u32 {
    use std::time::{SystemTime, UNIX_EPOCH};
    let seed = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().subsec_nanos();
    // 练习用简单 LCG 即可,生产用 rand crate
    (seed as u32).wrapping_mul(2654435761).rotate_left(7) & 0xffff
}

UI/上层拿到的永远是值对象:Session/Message 是 Clone + Serialize 的 DTO,跨 FFI 边界只传这些干净类型(16 课教训:别让复杂对象裸奔过桥)。

17.4 接口先行:store 与 llm 的 trait 边界

先定"能做什么",再定"怎么实现"。用 trait 把 IO 依赖抽象出来,核心逻辑只依赖接口——这正是第 8 课 trait 的实战形态:

// store.rs —— 存储层契约(第 18 课给 sqlite 实现)
pub trait HistoryStore: Send + Sync {
    fn create_session(&self, session: &Session) -> Result<(), StoreError>;
    fn list_sessions(&self, limit: u32) -> Result<Vec<Session>, StoreError>;
    fn delete_session(&self, session_id: &str) -> Result<(), StoreError>;

    fn append_message(&self, msg: &Message) -> Result<(), StoreError>;
    fn list_messages(&self, session_id: &str) -> Result<Vec<Message>, StoreError>;
    fn clear_messages(&self, session_id: &str) -> Result<(), StoreError>;
}

#[derive(Debug, thiserror::Error)]
pub enum StoreError {
    #[error("存储不可用:{0}")]
    Io(String),
    #[error("记录不存在:{0}")]
    NotFound(String),
}
// llm/chat.rs —— LLM 桥契约(第 19 课给 OpenAI/兼容端点实现)
use crate::models::{Message, Role};

/// 一段"增量"输出
#[derive(Debug, Clone)]
pub struct Delta {
    pub content: String,
}

#[derive(Debug, thiserror::Error)]
pub enum LlmError {
    #[error("请求失败:{0}")]
    Http(String),
    #[error("上游错误:{0}")]
    Upstream(String),
    #[error("流中断(已接收部分内容)")]
    StreamInterrupted,
    #[error("超时")]
    Timeout,
    #[error("配置缺失:{0}")]
    Config(String),
}

/// LLM 客户端契约:非流式问一句、流式边生成边回调
#[async_trait::async_trait]
pub trait ChatLlm: Send + Sync {
    /// 一次性回答(简单场景/测试桩)
    async fn complete(&self, messages: &[Message]) -> Result<String, LlmError>;

    /// 流式回答:每收到一段增量调用一次 on_delta,返回完整文本
    async fn stream_complete(
        &self,
        messages: &[Message],
        on_delta: Box<dyn FnMut(&str) + Send>,
    ) -> Result<String, LlmError>;
}

💡 async_trait crate 解决"trait 里放 async fn"的经典问题(trait 对象不能带 async fn),实战惯例是 #[async_trait::async_trait] 宏标注。Rust 2024 后原生 async fn in trait 逐步可用,但生态里 async_trait 仍是稳妥默认。

17.5 领域服务:把 LLM 与存储编排成"用例"

UI 层不直接摸 LLM/DB,它只跟 AssistantService 打交道——这层也叫"用例层/应用服务":

// service.rs
use crate::llm::chat::ChatLlm;
use crate::models::{Message, Role, Session};
use crate::store::HistoryStore;

/// 提供给 UI 的"一个用例 = 一个方法"
pub struct AssistantService {
    llm: Box<dyn ChatLlm>,
    store: Box<dyn HistoryStore>,
    system_prompt: String,
}

impl AssistantService {
    pub fn new(
        llm: Box<dyn ChatLlm>,
        store: Box<dyn HistoryStore>,
        system_prompt: impl Into<String>,
    ) -> Self {
        AssistantService { llm, store, system_prompt: system_prompt.into() }
    }

    /// 新建会话
    pub async fn new_session(&self, title: &str) -> Result<Session, ServiceError> {
        let session = Session::new(title);
        self.store.create_session(&session)?;
        Ok(session)
    }

    /// 列出历史会话(UI 首页用)
    pub fn list_sessions(&self, limit: u32) -> Result<Vec<Session>, ServiceError> {
        Ok(self.store.list_sessions(limit)?)
    }

    /// 某会话的消息列表(UI 打开会话页用)
    pub fn messages(&self, session_id: &str) -> Result<Vec<Message>, ServiceError> {
        Ok(self.store.list_messages(session_id)?)
    }

    /// 流式问答(核心用例):
    /// 1) 取出该会话历史 + 系统提示 → 组 messages
    /// 2) 调 LLM 流式,增量回调上层
    /// 3) 把新问答落库
    pub async fn ask_stream(
        &self,
        session_id: &str,
        question: &str,
        on_delta: Box<dyn FnMut(&str) + Send>,
    ) -> Result<(), ServiceError> {
        // 取历史 + 在最前插入系统提示(role = System)
        let mut history = self.store.list_messages(session_id)?;
        history.insert(
            0,
            Message {
                id: crate::models::uuid_like_id(),
                session_id: session_id.to_string(),
                role: Role::System,
                content: self.system_prompt.clone(),
                created_at_ms: crate::models::now_ms(),
            },
        );

        let user_msg = Message {
            id: crate::models::uuid_like_id(),
            session_id: session_id.to_string(),
            role: Role::User,
            content: question.to_string(),
            created_at_ms: crate::models::now_ms(),
        };
        history.push(user_msg.clone());

        // 流式调用(骨架:第 15 课的 consume_sse 引擎在这里接入)
        let answer = self
            .llm
            .stream_complete(&history, on_delta)
            .await
            .map_err(ServiceError::from)?;

        let assistant_msg = Message {
            id: crate::models::uuid_like_id(),
            session_id: session_id.to_string(),
            role: Role::Assistant,
            content: answer,
            created_at_ms: crate::models::now_ms(),
        };
        self.store.append_message(&user_msg)?;
        self.store.append_message(&assistant_msg)?;
        Ok(())
    }

    /// 删除会话
    pub fn delete_session(&self, session_id: &str) -> Result<(), ServiceError> {
        self.store.delete_session(session_id)?;
        self.store.clear_messages(session_id)?;
        Ok(())
    }
}

错误统一向上收成一种 ServiceError(内部 #[from] 两个子错误,7 课手艺):

#[derive(Debug, thiserror::Error)]
pub enum ServiceError {
    #[error("存储错误:{0}")]
    Store(#[from] crate::store::StoreError),
    #[error("LLM 错误:{0}")]
    Llm(#[from] crate::llm::chat::LlmError),
    #[error("会话不存在")]
    SessionNotFound,
}

💡 注意 ask_stream 的落库策略:“先把问题存了,回答流式回来再存”。中途断流时已保存用户问题、但 assistant 答案不完整——19 课会给消息加 complete: bool 之类标记,这里先留出演进空间。

17.6 测试策略:不给外部留后门,用"假实现"测核心

第 10 课讲过三种测试。core 层"接口先行"的红利现在就兑现——给 trait 写内存假实现,核心逻辑不需要真实 LLM/DB 就能测:

// core/tests/service_test.rs —— 集成测试(只经过公开 API)
use my_ai_core::llm::chat::{ChatLlm, LlmError};
use my_ai_core::models::{Message, Session};
use my_ai_core::service::AssistantService;
use my_ai_core::store::{HistoryStore, StoreError};
use std::sync::Mutex;

// 假的 LLM:不联网,收到流式请求就吐两段固定增量
struct FakeLlm;
#[async_trait::async_trait]
impl ChatLlm for FakeLlm {
    async fn complete(&self, _messages: &[Message]) -> Result<String, LlmError> {
        Ok("你好,世界".into())
    }
    async fn stream_complete(
        &self,
        _messages: &[Message],
        mut on_delta: Box<dyn FnMut(&str) + Send>,
    ) -> Result<String, LlmError> {
        on_delta("你好,");
        on_delta("世界");
        Ok("你好,世界".into())
    }
}

// 假的 store:用内存 Vec 实现 HistoryStore(第 18 课会换成 sqlite,测试不用改)
#[derive(Default)]
struct MemStore {
    sessions: Mutex<Vec<Session>>,
    messages: Mutex<Vec<Message>>,
}
impl HistoryStore for MemStore {
    fn create_session(&self, s: &Session) -> Result<(), StoreError> {
        self.sessions.lock().unwrap().push(s.clone());
        Ok(())
    }
    fn list_sessions(&self, _limit: u32) -> Result<Vec<Session>, StoreError> {
        Ok(self.sessions.lock().unwrap().clone())
    }
    fn delete_session(&self, id: &str) -> Result<(), StoreError> {
        self.sessions.lock().unwrap().retain(|s| s.id != id);
        Ok(())
    }
    fn append_message(&self, m: &Message) -> Result<(), StoreError> {
        self.messages.lock().unwrap().push(m.clone());
        Ok(())
    }
    fn list_messages(&self, session_id: &str) -> Result<Vec<Message>, StoreError> {
        Ok(self
            .messages
            .lock()
            .unwrap()
            .iter()
            .filter(|m| m.session_id == session_id)
            .cloned()
            .collect())
    }
    fn clear_messages(&self, session_id: &str) -> Result<(), StoreError> {
        self.messages.lock().unwrap().retain(|m| m.session_id != session_id);
        Ok(())
    }
}

#[tokio::test]
async fn ask_stream_persists_question_and_answer() {
    let service = AssistantService::new(
        Box::new(FakeLlm),
        Box::new(MemStore::default()),
        "你是课程助教".to_string(),
    );
    let session = service.new_session("测试").await.unwrap();

    let mut got = String::new();
    service
        .ask_stream(&session.id, "介绍一下自己", Box::new(move |d| got.push_str(d)))
        .await
        .unwrap();

    assert_eq!(got, "你好,世界");
    let msgs = service.messages(&session.id).unwrap();
    assert_eq!(msgs.len(), 2);               // user + assistant 各一条
    assert!(msgs[0].content.contains("介绍一下自己"));
}

关键纪律:测试只通过 pub API 驱动,断言行为而不是实现细节(内存假 store 也要通过同一 trait 用)。将来把 MemStore 换成 sqlite(18 课)或把 FakeLlm 换成真客户端(19 课),service 测试一行都不用改——这就是"面向接口编程"的验收标准。

17.7 console-demo:无 UI 的端到端自检

每课写一点,最后 console-demo 就是个"命令行版小助手":

// console-demo/src/main.rs
use my_ai_core::llm::chat::ChatLlm;
use my_ai_core::service::AssistantService;
use my_ai_core::store::HistoryStore;

struct EchoLlm;   // 演示期:把输入 echo 回来;19 课换成真实 OpenAI 客户端

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let service = AssistantService::new(
        Box::new(EchoLlm),
        Box::new(/* sqlite 实现,18 课后 */),
        "你是课程助教".to_string(),
    );

    let session = service.new_session("第17课").await?;
    println!("会话已建: {}", session.title);

    let mut text = String::new();
    service
        .ask_stream(&session.id, "Rust 为什么安全?", Box::new(|d| {
            text.push_str(d);
        }))
        .await?;
    println!("助手:{text}");

    // 再列出历史验证落库
    for s in service.list_sessions(10)? {
        println!("历史会话: {} ({})", s.title, s.created_at_ms);
    }
    Ok(())
}

17.8 📝 动手练习

  1. 搭 workspace:按 17.2 建 core/ffi-bindings/console-demo,跑通 cargo build -p core。
  2. 补全模型:给 Role 实现 as_str() 与 Display;给 Message 加 #[serde(rename_all="camelCase")] 版本并 round-trip。
  3. 写 MemStore:用 Arc<Mutex<Vec<...>>>(12 课)实现 HistoryStore 的内存版,30-50 行。
  4. Service 空跑测试:让 ask_stream 的 service 测试跑绿(FakeLlm + MemStore)。
  5. 错误注入:让 FakeLlm 返回 LlmError::StreamInterrupted,断言 service 把它转成 ServiceError 且原样上抛(不吞错)。
  6. 权限自检:尝试在测试外构造带空标题的 Session(应编译不过/构造器拦截),说明为什么"构造器即防线"。

验收门禁:能画出 core 的模块分层图并说出每层职责;能解释"为什么 LLM/DB 必须抽象成 trait";能说清领域服务如何成为 UI 唯一入口。

✅ 本节小结

  • 分工:业务核心进 Rust(LLM/SSE/存储/权限),壳只做 UI;
  • 结构:workspace(core + ffi-bindings + console-demo),core 是纯 lib;
  • 模型:DTO 用拥有型 + #[derive];构造器拦截非法状态;
  • 接口:HistoryStore/ChatLlm 两个 trait 抽象 IO;错误用 thiserror 精确化;
  • 编排:AssistantService 是把"存储 + LLM + 系统提示"编排成用例的唯一入口;
  • 测试红利:面向接口 → 内存假实现 → 核心逻辑无网络可测;
  • 路线:17 骨架 → 18 真存储 → 19 真 LLM/UniFFI → 20-21 壳与发布。

下一课预告:第 18 课《SQLite 持久化与本地缓存》——给 store trait 填上真实现:rusqlite 连接管理、表结构与迁移、参数化查询防注入、把内存假 store 换成 sqlite 并让所有测试原样跑绿。数据库的"最后 20% 坑"(忙锁、连接、并发)也会提前踩一遍。

Logo

一站式 AI 云服务平台

更多推荐