11 Commits
Author SHA1 Message Date
徐涛 32d886f870 feat(memory): 新增 VectorStore 抽象与 RagPipeline 持久化管线
- 新增 src/memory/vector_store.rs(约 660 行):

  - VectorStore trait:批量 add / search / remove + add_one 默认实现

  - InMemoryVectorStore:Mutex<HashMap> + 余弦全量扫描(锁内克隆、锁外计算)

  - PersistentVectorStore:MemoryStore 包装,JSON blob 持久化,先持久化后内存

  - RagPipeline:split → embed → store 组合器(具体 struct,非 trait)

- 22 个内联测试覆盖 InMemory(10)/ Persistent(6)/ RagPipeline(4)/ 性能基准(2)

- 性能断言:search 10K 条 <100ms,PersistentVectorStore::new 加载 <500ms

- 标记 VectorRetriever / InMemoryVectorRetriever 为 #[deprecated(since="0.3.0")]

- memory.rs 追加 VectorStore 等 4 个类型的 re-export

- document_demo 从手动 VectorRetriever 循环迁移到 RagPipeline 两行调用

- 零新增外部依赖
2026-07-09 16:51:29 +08:00
徐涛 b04427e83f docs(roadmap): 标记 Phase 14 完成 + M10 里程碑达成
Phase 14 全部交付物已完成(commit d4c4d8f):
- Document 类型 + RecursiveCharacterSplitter 分割器
- Embedding trait + MockEmbedding
- 19 Document 测试 + 6 Embedding 测试
- 全量测试 286 → 313,clippy 0 警告,零新外部依赖

Roadmap 同步 6 处:
- 顶部最后更新日期 + 当前状态(Phase 14 完成,Phase 15-19 待实施)
- §Phase 14 章节新增实际新增段落(commit hash + 文件清单 + 设计决策 + 测试覆盖)
- v0.3.0 依赖图 P14 节点 pending → done
- M10 里程碑  2026-07-09
- 下一步行动从 Phase 14 启动改为 Phase 15 启动
- 已完成/进行中阶段列表追加 Phase 14 完成条目
2026-07-09 13:27:36 +08:00
徐涛 d4c4d8fa3c feat(document): 实现 Document 类型与 RecursiveCharacterSplitter 分割器
新增 Phase 14 核心模块,为 RAG 管线提供 split → embed 阶段的底层支撑。

新增内容:
- Document 类型(id/content/metadata/mime_type 四字段 + new/from_raw 构造器)
- RecursiveCharacterSplitter(两阶段算法:按 separator 优先级递归分割 + 贪心合并 overlap 滑动窗口)
- Embedding trait(异步向量化抽象,复用 LlmError)+ MockEmbedding(sin-hash 零依赖伪随机实现)
- 19 个 Document 单元测试 + 6 个 Embedding 单元测试
- document_demo 示例(Document → Splitter → MockEmbedding → InMemoryVectorRetriever 端到端演示)

模块注册:
- src/lib.rs: pub mod document + pub use Document
- src/llm.rs: pub mod embedding

设计文档:docs/20-phase14-document-and-embedding.md(1417 行,含背景/调研/方案对比/实施计划)

零新外部依赖,所有长度比较以 Unicode 字符为单位(chars_len),CJK 文本行为正确。
2026-07-09 13:08:35 +08:00
徐涛 4686063ca8 docs: 标记 Phase 13 完成 + 追加 v0.3.0 CHANGELOG 条目
- CHANGELOG.md: 追加 v0.3.0 (unreleased) 条目
  - Breaking Changes: 类型路径变更(request.rs/response.rs → provider/openai.rs,
    公共 ToolChoice re-export 路径不变)+ ChatResponse / LegacyStreamEvent 删除
  - Added: ContextSlot::fork / merge + MergeStrategy 枚举(#[non_exhaustive])
  - Changed: 3 个旧 types 文件删除 + 所有 wire-format 类型迁入 openai.rs
  - Fixed: Phase 9 实施审查修复(PreRequest hook + 死代码清理 + 2 个集成测试)
  - Migration Guide: v0.2.0-rc.1 → v0.3.0 路径迁移示例
  - 修复 M9 里程碑验收 #11(CHANGELOG 条目)和审查结论 CONDITIONAL PASS 条件清单 #1
- docs/roadmap.md:
  - 顶部最后更新日期 → 2026-07-08(Phase 13 完成 + M9 里程碑达成)
  - 末尾"v0.3.0 规划完成"状态行从"Phase 13-19 待逐步实施"更新为"Phase 13 完成,
    Phase 14-19 待实施(Document → 向量存储 → 摘要 → 引擎 → 调度 → 知识图谱)"
2026-07-09 06:19:43 +08:00
徐涛 c36668071e fix(agent): Phase 9 实施审查修复
- cycle.rs: run_tool_loop 实现 PreRequest hook(之前 `let _ = hook_executor.as_ref()` 是空操作,
  导致 hook-based logging/monitoring 在流式工具循环中失效;现在与 submit_with_tools 行为对齐,
  含 should_block 检查,阻断时通过 StreamEvent::Error 事件化)
- session.rs: 删除 submit_turn_stream 末尾的 `let _ = hook_executor;` 死代码(Arc 引用生命周期
  由 Arc 自动管理)
- session.rs: 新增 2 个集成测试覆盖方案 §4 Step 5:
  - submit_turn_stream_end_to_end:mock provider → 消费流 → finalize_turn 后 cost_so_far
    正确更新(10/5 tokens)+ turn_index=1 + default slot 包含 user/assistant 消息
  - submit_turn_stream_triggers_turn_hooks:OnTurnStart 在 submit_turn_stream 返回流前
    触发(计数=1)+ OnTurnEnd 在 finalize_turn 前不触发(计数=0)+ finalize_turn 后触发(计数=1)
- docs/16-phase9-streaming-experience.md: 标注 finalize_turn Phase 10 签名变更(new_messages_from_cycle
  + Result 返回),Step 5 测试实现位置
- 测试 288 passed / 0 failed(基线 286 + 2 新增),clippy 0 警告,doc 0 warning
2026-07-08 23:19:24 +08:00
徐涛 d4f27b5865 refactor(types): 删除旧类型文件和 ChatResponse
- 删除 src/llm/types/old_stream.rs(45 行,LegacyStreamEvent 内部死代码)
- types/mod.rs 删除 pub mod old_stream; 与 ChatResponse 结构体定义
- 前置验证 A 通过:parse_chunk_stream / map_legacy_to_ir / LegacyToIrEventStream /
  ChunkToLegacyEventStream / LegacyStreamEvent 零外部调用方
- 前置验证 B 通过:cargo doc --no-deps 中无 ChatResponse 引用
- cycle.rs:88 的 #[allow(deprecated)] 保留(LlmCycle impl 中无 ChatResponse 引用,
  与 ChatResponse 无关;按方案文档"若不相关则无需改动"原则保留原状)
- stream.rs 已在 Step 13.2 commit 中同步简化为 module doc + pub use
- 同步更新 docs/roadmap.md:Phase 13 状态 ,M9 里程碑 + 完成日期,
  Mermaid 依赖图 P13 pending→done,"下一步"指向 Phase 14
- Breaking Change:agcore::llm::types::ChatResponse 已删除
  (v0.1.0 起标记 #[deprecated],请改用 MessageResponse)
2026-07-08 22:59:31 +08:00
徐涛 1c0e1e0ed1 refactor(types): response.rs 类型移入 provider/openai.rs
- 删除 types/response.rs(177 行)
- 所有 OpenAI wire-format 响应类型迁入 provider/openai.rs,可见性 pub(crate):
  TokenLogprob / TopLogprob / Logprobs / URLCitation / Annotation / OpenaiAudio /
  Choice / OpenaiChatResponse / Delta / ChunkChoice / OpenaiChatChunk
- From<OpenaiChatMessage> for Delta 与 From<OpenaiChatResponse> for OpenaiChatChunk
  同步迁入 openai.rs
- types/mod.rs 删除 pub mod response; 与对应 re-export
- convert_response 同步降级为 pub(crate) 以匹配 OpenaiChatResponse 可见性
- stream.rs: OpenaiChatChunk import 路径改为 crate::llm::provider::openai
- stream.rs 同步简化为 module doc + pub use 重导出(合并 Step 13.3 的清理动作,
  避免遗留 dead_code 警告来回)
- mod.rs: ChatResponse 的两个 From impl 同步删除(impl 内引用的 OpenaiChatResponse
  / OpenaiChatChunk / Delta / ChunkChoice 已不在 types 模块),结构体保留到 Step 13.3
- 公共 re-export 路径 agcore::llm::types::OpenaiChatResponse/Chunk 等已删除
  (Breaking Change,见 CHANGELOG)
2026-07-08 22:58:18 +08:00
徐涛 760de46623 refactor(types): request.rs 类型移入 provider/openai.rs
- 删除 types/request.rs(187 行)
- 所有 OpenAI wire-format 类型迁入 provider/openai.rs,可见性 pub(crate):
  StreamOptions / OpenaiTool / AudioParam / PredictionContent / UserLocation /
  Approximate / WebSearchOptions / OpenaiChatRequest
- types/mod.rs 删除 pub mod request; 与对应 re-export
- convert_request 同步降级为 pub(crate) 以匹配 OpenaiChatRequest 可见性
- 公共 re-export 路径 agcore::llm::types::OpenaiChatRequest 等已删除(Breaking Change,见 CHANGELOG)
2026-07-08 22:55:17 +08:00
徐涛 f8df6a9421 refactor(types): ToolChoice 移入 tool.rs
- ToolChoice 枚举与 serde impl 从 types/request.rs 迁入 types/tool.rs
- mod.rs re-export 从 request::ToolChoice 改为 tool::ToolChoice(公共 agcore::llm::types::ToolChoice 路径保持不变)
- request_v2.rs import 路径更新为 crate::llm::types::tool::ToolChoice
- OpenaiChatRequest 字段类型引用更新为 super::tool::ToolChoice(Step 13.1 删除 request.rs 后此临时 import 同步消除)
2026-07-08 22:53:46 +08:00
徐涛 802518b5fe feat(agent): 实现 ContextSlot fork/merge
- 新增 MergeStrategy 枚举(#[non_exhaustive],为 Phase 16 Summarize 预留)
- 新增 ContextSlot::fork() 从父 slot 派生独立子 slot(Full / Focused 策略)
- 新增 ContextSlot::merge() 将子 slot 消息合入父 slot(Append / Replace 策略)
- 防御性检查:禁止 self-merge、跨 session merge、合并到 Readonly slot
- 重构 AgentSession::derive_slot 复用 fork() 消除重复代码
- 新增 9 个内联测试覆盖 fork/merge happy path 与 error path
- agent.rs 追加 MergeStrategy re-export(agcore::agent::MergeStrategy 路径可用)
2026-07-08 22:52:38 +08:00
徐涛 993118f661 docs(roadmap): 将 v0.3+ 展望更新为 v0.3.0 详细规划
新增 Phase 13-19 共 7 个增量交付阶段,覆盖技术债清理、Document 系统、向量
存储持久化、摘要自动生成、Agent 执行引擎、Agent Switch 与 SubAgent 调度、
知识图谱及双通道检索。同步更新里程碑、依赖关系图及风险说明
2026-07-06 23:10:18 +08:00
26 changed files with 6115 additions and 758 deletions
+63
View File
@@ -2,6 +2,69 @@
本项目所有重要变更均记录于此文件。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 本项目所有重要变更均记录于此文件。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
## [0.3.0] - 未发布
v0.3.0 首个增量 Phase。技术债清理 + ContextSlot fork/merge + Phase 9 审查修复。
### Breaking Changes
**类型路径变更(0.3.0):**
- `agcore::llm::types::request::ToolChoice``agcore::llm::types::tool::ToolChoice`(公共 re-export 路径 `agcore::llm::types::ToolChoice` 保持不变)
- `agcore::llm::types::request::StreamOptions``agcore::llm::provider::openai::StreamOptions`
- `agcore::llm::types::request::OpenaiChatRequest``agcore::llm::provider::openai::OpenaiChatRequest`
- `agcore::llm::types::response::OpenaiChatResponse``agcore::llm::provider::openai::OpenaiChatResponse`
- `agcore::llm::types::response::OpenaiChatChunk``agcore::llm::provider::openai::OpenaiChatChunk`
- 其余 `request.rs`/`response.rs` 中的 wire-format 类型(`OpenaiTool``AudioParam``Choice``Delta``ChunkChoice``Annotation``Logprobs``TokenLogprob``URLCitation` 等)同步移入 `agcore::llm::provider::openai` 模块,可见性 `pub(crate)`
**类型删除:**
- `agcore::llm::types::ChatResponse` 已删除(自 v0.1.0 标记 `#[deprecated]`,请改用 `MessageResponse`
- `agcore::llm::types::old_stream::LegacyStreamEvent` 已删除(内部死代码)
**模块签名变化:**
- `LlmCycle::convert_request` / `convert_response``pub` 降级为 `pub(crate)`(因依赖的 `OpenaiChatRequest` / `OpenaiChatResponse``pub(crate)`
### Added
**Phase 13 — ContextSlot fork/merge**
- `ContextSlot::fork(child_id, strategy)` — 从父槽派生独立子槽(数据层操作,不持久化;调用方需自行 `save()`
- `ContextSlot::merge(child, strategy)` — 将子槽消息合并回父槽(`Append` 追加 / `Replace` 替换两种策略)
- `MergeStrategy` 枚举(`#[non_exhaustive]`Phase 16 可扩展 `Summarize`
- `MergeStrategy` 防御性检查:禁止 self-merge / 跨 session merge / 合并到 Readonly slot
- `agcore::agent::MergeStrategy` 公共 re-export 路径可用
- `AgentSession::derive_slot` 重构复用 `fork()` 消除重复代码(行为不变)
**Phase 9 实施审查修复(2026-07-08**
- 2 个集成测试覆盖方案 §4 Step 5:`submit_turn_stream_end_to_end` + `submit_turn_stream_triggers_turn_hooks`
### Changed
**Phase 13 — 技术债清理**
- 3 个旧 types 文件删除(`src/llm/types/request.rs` 187 行 + `response.rs` 177 行 + `old_stream.rs` 45 行)
- 所有 OpenAI wire-format 类型迁入 `provider/openai.rs`,可见性 `pub(crate)`
- `src/llm/stream.rs` 简化为 module doc + `pub use` 重导出(保持 `use crate::llm::stream::StreamEvent` 路径兼容,零下游破坏)
- `ToolChoice``request.rs` 迁入 `tool.rs`serde impl 原样搬入)
**Phase 9 实施审查修复**
- `LlmCycle::run_tool_loop` 实现 `PreRequest` hook(之前 `let _ = hook_executor.as_ref()` 是空操作,导致 hook-based logging/monitoring 在流式工具循环中失效;现在与 `submit_with_tools` 行为对齐,含 `should_block` 检查,阻断时通过 `StreamEvent::Error` 事件化)
### Fixed
**Phase 9 实施审查修复**
- `AgentSession::submit_turn_stream` 末尾 `let _ = hook_executor;` 死代码移除(Arc 引用生命周期由 Arc 自动管理)
### Migration Guide (v0.2.0-rc.1 → v0.3.0)
```rust
// ❌ v0.2.0-rc.1 — 已删除
use agcore::llm::types::ChatResponse;
use agcore::llm::types::request::OpenaiChatRequest;
// ✅ v0.3.0 — 替代路径
use agcore::llm::types::MessageResponse; // ChatResponse → MessageResponse
// OpenAI wire-format 类型为内部使用,不再公共 re-export
// 如需自定义 Provider,请直接 import agcore::llm::provider::openai::*(当前 pub(crate)
```
## [0.2.0-rc.1] - 2026-07-05 ## [0.2.0-rc.1] - 2026-07-05
v0.2.0 候选发布。Phase 5-7 三大 P0 全部交付完成,API 稳定性扫尾,新增 2 个面向新用户的集成示例。 v0.2.0 候选发布。Phase 5-7 三大 P0 全部交付完成,API 稳定性扫尾,新增 2 个面向新用户的集成示例。
+9 -3
View File
@@ -241,6 +241,12 @@ pub async fn submit_turn_stream(
pub async fn finalize_turn(&mut self, response: &MessageResponse) pub async fn finalize_turn(&mut self, response: &MessageResponse)
``` ```
> **实施偏差(Phase 10 适配)**:实际签名扩展为
> `pub async fn finalize_turn(&mut self, response: &MessageResponse, new_messages_from_cycle: Vec<Message>) -> Result<(), AgentError>`。
> - `new_messages_from_cycle`:本轮新增消息(`[user_input, ...tool_results, final_response]`),由消费者在流消费完毕后从 `cycle.messages()[input_len..]` 提取并传入;`finalize_turn` 增量追加到当前 slot(不覆盖已有消息)。
> - 返回 `Result<(), AgentError>`:错误传播更清晰,与 `submit_turn` 的 slot 边界错误(`SlotReadonly` / `SlotNotFound`)对齐。
> - Phase 10 ContextSlot 实施时扩展。Phase 9 消费者若不接入 slot 持久化,可传 `vec![response.message.clone()]` 兜底。
### 3.5 消费者使用模式 ### 3.5 消费者使用模式
```rust ```rust
@@ -410,10 +416,10 @@ if let Some(response) = final_response {
**目标**:端到端验证 `submit_turn_stream` + `finalize_turn` 的完整链路,确保零回归。 **目标**:端到端验证 `submit_turn_stream` + `finalize_turn` 的完整链路,确保零回归。
**新增**`agent/session.rs` 内联测试): **新增**`agent/session.rs` 内联测试2026-07-08 实施审查补全):
- **场景**`submit_turn_stream` 跑通 mock provider → 消费流(验证各事件到达`finalize_turn` 后 cost 更新正确 - `submit_turn_stream_end_to_end``submit_turn_stream` 跑通 mock provider → 消费流(验证收到 TextDelta + MessageComplete`finalize_turn``cost_so_far` 正确更新(`prompt_tokens=10, completion_tokens=5` + `turn_index=1` + default slot 包含 user/assistant 消息
- **场景**verify `OnTurnStart` hook`submit_turn_stream` 返回流之前已触发 - `submit_turn_stream_triggers_turn_hooks` — 验证 `OnTurnStart``submit_turn_stream` 返回流之前已触发(计数=1+ `OnTurnEnd``finalize_turn` 之前**不**触发(计数=0+ `finalize_turn``OnTurnEnd` 触发(计数=1
**验证** **验证**
+640
View File
@@ -0,0 +1,640 @@
# Phase 13 — 热身清理 + ContextSlot fork/merge 实施方案
- **文档编号**19
- **标题**Phase 13 — 热身清理 + ContextSlot fork/merge 实施方案
- **日期**2026-07-08
- **状态**:待实施
- **涉及模块**agent/context、agent/session、llm/types、llm/provider/openai、llm/stream
- **关联文档**roadmap.md(§Phase 13)、17-phase10-contextslot.md
- **对应**Roadmap §Phase 13v0.3.0 第一阶段)
---
## 1. 背景与目标
v0.3.0 是 agcore 从"LLM 调用工具箱"升级为"多 Agent 基础系统"的关键版本。Phase 13 是 v0.3.0 的第一阶段,定位为"热身",包含两大部分:
- **技术债清理**:删除 Phase 0 遗留的旧 types 文件(`request.rs``response.rs``old_stream.rs`),以及已标记 `#[deprecated]``ChatResponse` 结构体
- **ContextSlot fork/merge**:为 ContextSlot 增加分叉和合并能力,为后续 Phase 17 Checkpointer 和 Phase 18 SubAgent Dispatch 打基础
**依赖关系**:无(独立交付)
**优先级**P0
**预估规模**:净减 ~200 行代码(新增 ~505 行,删除 ~704 行)
---
## 2. 需求分析
### 2.1 功能需求
1. **技术债清理**:删除 `src/llm/types/request.rs`187 行)、`response.rs`177 行)、`old_stream.rs`45 行),将其中的 OpenAI wire-format 类型移入 `src/llm/provider/openai.rs`;删除 `types/mod.rs` 中的 `ChatResponse` 废弃结构体
2. **`ContextSlot::fork`**:从现有 context slot 分支出独立的子 slot
3. **`ContextSlot::merge`**:将子 slot 的消息合并回父 slot
4. **`MergeStrategy`** 枚举:Append(追加)/ Replace(替换),`#[non_exhaustive]` 预留 Phase 16 Summarize 扩展
### 2.2 非功能需求
- **每步可编译**:5 个 Step 按物理文件切割,每步 `cargo build --all-targets + cargo test` 验证
- **指定公共 API 路径保持向后兼容**:`agcore::llm::types::ToolChoice`re-export 不变)、`crate::llm::stream::StreamEvent`(重导出保留);其余 wire-format 类型(`OpenaiChatRequest``OpenaiChatResponse/Chunk``StreamOptions` 等)移入 `provider/openai.rs` 后属 Breaking Change,详见 §4.3 CHANGELOG
- **向后兼容的 StreamEvent 路径**`crate::llm::stream::StreamEvent` 重导出保留,不修改 `cycle.rs``session.rs` 的 import
---
## 3. 方案设计
### 3.1 整体架构
Phase 13 分为 5 个 Step,按执行顺序排列:
```
Step 13.5 (fork/merge) → Step 13.4 (ToolChoice) → Step 13.1 (request types) → Step 13.2 (response types) → Step 13.3 (cleanup)
```
这种顺序的好处:
- **先交付价值**:13.5 是唯一有用户功能交付的 Step,先做建立节奏
- **排序约束**13.4 必须先于 13.1ToolChoice 不搬走,request.rs 不能删)
- **13.3 收尾**:删除旧文件和 `ChatResponse` 是 breaking change,放在最后
### 3.2 Step 13.5 — ContextSlot fork/merge
#### MergeStrategy 枚举
定义在 `src/agent/context.rs`
```rust
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum MergeStrategy {
/// 子 slot 消息追加到父 slot 末尾。
Append,
/// 用子 slot 消息替换父 slot 内容。
Replace,
}
```
- `#[non_exhaustive]` 保证 Phase 16 加入 `Summarize` 变体时不破坏现有代码
- 不预埋 `Summarize` 占位变体(YAGNI 原则)
#### ContextSlot::fork
```rust
impl ContextSlot {
pub fn fork(&self, child_id: String, strategy: DeriveStrategy) -> ContextSlot {
let messages = match &strategy {
DeriveStrategy::Full => self.messages.clone(),
DeriveStrategy::Focused(cfg) => Self::filter_focused(&self.messages, cfg),
};
tracing::debug!(
parent_id = %self.id,
child_id = %child_id,
?strategy,
"ContextSlot::fork"
);
ContextSlot {
id: child_id,
session_id: self.session_id.clone(),
config: SlotConfig {
mode: match &strategy {
DeriveStrategy::Full => SlotMode::Full,
DeriveStrategy::Focused(cfg) => SlotMode::Focused(cfg.clone()),
},
source: SlotSource::Derived {
parent_id: self.id.clone(),
strategy,
},
budget: self.config.budget.clone(),
compact: self.config.compact,
},
messages,
meta: SlotMeta::new(),
}
}
}
```
设计要点:
- 纯数据层操作,不持久化
- 子 slot 的 `meta` 全新创建(`SlotMeta::new()`),不继承父 slot 的 message_count
- 子 slot 的 source 记录 `parent_id`,血缘可追溯
- 添加 `tracing::debug!` 日志,支持多 slot 交互场景的审计追踪
#### ContextSlot::merge
```rust
impl ContextSlot {
/// 将子 slot 的消息合并到当前 slot。
///
/// **注意**:本方法仅操作内存数据,不自动持久化。
/// 调用方需在 merge 后自行调用 `self.save(&store)` 将结果写入后端存储。
pub fn merge(&mut self, child: ContextSlot, strategy: MergeStrategy) -> Result<(), AgentError> {
// 防御性检查
if self.id == child.id {
return Err(AgentError::Config("不能将 slot 合并到自身".into()));
}
if self.session_id != child.session_id {
return Err(AgentError::Config("不能合并不同 session 的 slot".into()));
}
if matches!(self.config.mode, SlotMode::Readonly) {
return Err(AgentError::SlotReadonly("Readonly slot 不允许合并".into()));
}
tracing::debug!(
self_id = %self.id,
child_id = %child.id,
?strategy,
"ContextSlot::merge"
);
match strategy {
MergeStrategy::Append => {
let count = child.messages.len();
self.messages.extend(child.messages);
self.meta.message_count += count;
}
MergeStrategy::Replace => {
self.messages = child.messages;
self.meta.message_count = self.messages.len();
}
}
Ok(())
}
}
```
#### AgentSession::derive_slot 重构
现有 `derive_slot`session.rs:213-260)的手工复制代码改为调用 `parent.fork()`
```rust
pub async fn derive_slot(
&mut self,
id: impl Into<String>,
parent_id: &str,
strategy: DeriveStrategy,
) -> Result<(), AgentError> {
let slot_id = id.into();
if self.slots.contains_key(&slot_id) {
return Err(AgentError::SlotAlreadyExists(slot_id));
}
let parent = self
.slots
.get(parent_id)
.ok_or_else(|| AgentError::SlotNotFound(parent_id.to_string()))?;
let child = parent.fork(slot_id.clone(), strategy); // ← 用 fork
child.save(&*self.resolve_store()).await?;
self.slots.insert(slot_id, child);
Ok(())
}
```
重复检查、查找父 slot 的代码不变;消息复制逻辑委托给 `fork()`
#### 测试计划(新增 9 个)
| 测试名 | 验证点 |
|--------|--------|
| `fork_full_copies_messages` | fork Full 策略复制父 slot 全部消息 |
| `fork_focused_filters_messages` | fork Focused 策略按 config 过滤 |
| `fork_preserves_independence` | 父 slot 追加消息不影响子 slot |
| `fork_sets_derived_source` | 子 slot source 正确记录 parent_id |
| `merge_append_appends_messages` | Append 追加到父 slot 末尾,message_count 正确 |
| `merge_replace_replaces_messages` | Replace 替换父 slot 消息,message_count 正确 |
| `merge_self_rejected` | self-merge 返回 `Err` |
| `merge_readonly_rejected` | 合并到 Readonly slot 返回 `Err` |
| `merge_cross_session_rejected` | 跨 session 合并返回 `Err` |
### 3.3 Step 13.4 — ToolChoice 移入 tool.rs
#### 变更文件
| 文件 | 变更 |
|------|------|
| `src/llm/types/request.rs` | 删除 `ToolChoice` 枚举 + serde impl~28-99 行) |
| `src/llm/types/tool.rs` | 新增 `ToolChoice` 枚举 + serde impl(原样搬入) |
| `src/llm/types/mod.rs` | `pub use request::{..., ToolChoice}``pub use tool::ToolChoice` |
| `src/llm/types/request_v2.rs` | import 路径 `request::ToolChoice``tool::ToolChoice` |
**import 路径变化**
| 当前 | 移动后 |
|------|--------|
| `crate::llm::types::request::ToolChoice` | `crate::llm::types::tool::ToolChoice` |
| `crate::llm::types::ToolChoice`(通过 re-export | `crate::llm::types::ToolChoice`(通过 tool.rs re-export,保持不变) |
**验证**`cargo build --all-targets` + `cargo test` + `cargo clippy`
### 3.4 Step 13.1 — request.rs 类型移入 openai.rs
#### 变更文件
| 文件 | 变更 |
|------|------|
| `src/llm/types/request.rs` | **整文件删除**187 行) |
| `src/llm/provider/openai.rs` | 新增 `StreamOptions``OpenaiTool``AudioParam``PredictionContent``UserLocation``Approximate``WebSearchOptions``OpenaiChatRequest` 等类型定义 |
| `src/llm/types/mod.rs` | 删除 `pub use request::{OpenaiChatRequest, OpenaiTool, StreamOptions}`;删除 `pub mod request;` |
| `src/llm/provider/openai.rs` import 调整 | 原 `use crate::llm::types::request::{...}` 改为从同级 `use super::super::types::...` 或直接使用本文件内类型 |
**注意**`OpenaiTool` 引用 `OpenaiToolDefinition`(定义在 `tool.rs`),移入 `openai.rs` 后需通过 `crate::llm::types::tool::OpenaiToolDefinition` 引用。`OpenaiChatRequest.messages` 字段引用 `OpenaiChatMessage`(定义在 `openai_message.rs`),路径不变。
**设计决策**:搬入 `openai.rs` 后的类型可见性可降级为 `pub(crate)`。它们是与 OpenAI wire-format 绑定的内部序列化类型,公共 API 消费者不应直接接触。
**验证**`cargo build --all-targets` + `cargo test` + `cargo clippy`
### 3.5 Step 13.2 — response.rs 类型移入 openai.rs
#### 变更文件
| 文件 | 变更 |
|------|------|
| `src/llm/types/response.rs` | **整文件删除**177 行) |
| `src/llm/provider/openai.rs` | 新增 `TokenLogprob``TopLogprob``Logprobs``URLCitation``Annotation``OpenaiAudio``Choice``OpenaiChatResponse``Delta``ChunkChoice``OpenaiChatChunk` + `From<OpenaiChatMessage> for Delta` + `From<OpenaiChatResponse> for OpenaiChatChunk` |
| `src/llm/types/mod.rs` | 删除 `pub use response::{...}`;删除 `pub mod response;` |
| `src/llm/stream.rs:26` | 将 `use crate::llm::types::{OpenaiChatChunk, OpenaiToolCall}` 中的 `OpenaiChatChunk` 路径改为 `crate::llm::provider::openai::OpenaiChatChunk``OpenaiToolCall` 保持从 `tool.rs` |
**验证**`cargo build --all-targets` + `cargo test` + `cargo clippy`
### 3.6 Step 13.3 — 旧文件清理 + ChatResponse 删除
#### 13.3a — 删除 `old_stream.rs`
> **前置验证**:实施前执行 `grep -rn 'parse_chunk_stream\|map_legacy_to_ir\|LegacyToIrEventStream\|ChunkToLegacyEventStream' src/` 确认零外部调用方,记录结果到实施 commit。
| 文件 | 变更 |
|------|------|
| `src/llm/types/old_stream.rs` | **整文件删除**45 行,`LegacyStreamEvent` |
| `src/llm/types/mod.rs` | 删除 `pub mod old_stream;` |
| `src/llm/stream.rs` | 删除 `use crate::llm::types::old_stream::LegacyStreamEvent`;删除 `parse_chunk_stream``parse_chunk_stream_legacy``ChunkToLegacyEventStream``LegacyToIrEventStream``map_legacy_to_ir``empty_message_response`~160 行死代码) |
**stream.rs 最终形态**
```rust
//! 流式事件系统 —— 重导出 StreamEvent 供向后兼容。
pub use crate::llm::types::response_v2::StreamEvent;
```
**为什么不全删 stream.rs**`cycle.rs``session.rs``use crate::llm::stream::StreamEvent` 路径保持不变。全删 + 改所有 import 路径的改动量 > 收益。保留 1 行重导出就够。
#### 13.3b — 删除 `ChatResponse`
| 文件 | 变更 |
|------|------|
| `src/llm/types/mod.rs` | 删除 `ChatResponse` 结构体定义 + 两个 `#[allow(deprecated)]` `From` impl`From<OpenaiChatResponse> for ChatResponse``From<ChatResponse> for OpenaiChatChunk` |
`ChatResponse` 自 v0.1.0 起标记 `#[deprecated]`v0.2.0-rc.1 阶段直接删除即可。删除前运行 `cargo doc --no-deps 2>&1 | grep -i 'ChatResponse'` 确认零文档引用。
**验证**`cargo build --all-targets` + `cargo test` + `cargo clippy` + `cargo doc --no-deps`
---
## 4. 实现计划
### 4.1 实施顺序总览
```
Step 13.5 ──→ Step 13.4 ──→ Step 13.1 ──→ Step 13.2 ──→ Step 13.3
(fork/merge) (ToolChoice) (request) (response) (cleanup)
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
+60 行净增 -0 净增 -0 净增 -0 净增 -260 删除
+9 个测试 import 路径 纯类型搬移 纯类型搬移 +1 行重导出
变更
```
### 4.2 各 Step 文件变更清单
#### Step 13.5 — ContextSlot fork/merge
| 操作 | 文件 | 变更说明 |
|------|------|---------|
| 新增 | `src/agent/context.rs` | `MergeStrategy` 枚举 + `ContextSlot::fork()` + `ContextSlot::merge()` |
| 重构 | `src/agent/session.rs` | `derive_slot` 改为调用 `parent.fork()` |
| 新增 | 内联测试 | 9 个新测试(fork, merge, 边界) |
#### Step 13.4 — ToolChoice 移动
| 操作 | 文件 | 变更说明 |
|------|------|---------|
| 删除 | `src/llm/types/request.rs` | 移除 `ToolChoice` 枚举 + serde impl |
| 新增 | `src/llm/types/tool.rs` | 增加 `ToolChoice` 枚举 + serde impl |
| 修改 | `src/llm/types/mod.rs` | 更新 re-export 路径 |
| 修改 | `src/llm/types/request_v2.rs` | 更新 import 路径 |
#### Step 13.1 — request 类型搬移
| 操作 | 文件 | 变更说明 |
|------|------|---------|
| 删除 | `src/llm/types/request.rs` | 整文件删除(187 行) |
| 新增 | `src/llm/provider/openai.rs` | 增加所有 OpenAI wire-format 类型 |
| 修改 | `src/llm/types/mod.rs` | 删除 re-export + mod 声明 |
#### Step 13.2 — response 类型搬移
| 操作 | 文件 | 变更说明 |
|------|------|---------|
| 删除 | `src/llm/types/response.rs` | 整文件删除(177 行) |
| 新增 | `src/llm/provider/openai.rs` | 增加所有 OpenAI wire-format 类型 + From impl |
| 修改 | `src/llm/types/mod.rs` | 删除 re-export + mod 声明 |
| 修改 | `src/llm/stream.rs` | 更新 `OpenaiChatChunk` import 路径 |
#### Step 13.3 — 旧文件清理
| 操作 | 文件 | 变更说明 |
|------|------|---------|
| 删除 | `src/llm/types/old_stream.rs` | 整文件删除(45 行) |
| 修改 | `src/llm/types/mod.rs` | 删除 `pub mod old_stream;` + 删除 `ChatResponse` 结构体 + `From` impl |
| 修改 | `src/llm/stream.rs` | 删除所有死代码,仅保留 `pub use` 重导出 |
### 4.3 回滚策略
所有 Step 通过 git commit 管理,回退时 `git revert <commit>` 即可。每个 Step 独立编译,回滚不会级联依赖。若 Step 13.3(`ChatResponse` 删除)导致外部编译失败,单独 revert 该 commit 即可恢复 `ChatResponse` + `old_stream.rs`
### 4.4 CHANGELOG 条目
```markdown
## [0.3.0] - 未发布
### Breaking Changes
**类型路径变更(0.3.0):**
- `agcore::llm::types::request::ToolChoice``agcore::llm::types::tool::ToolChoice`(公共 re-export 路径 `agcore::llm::types::ToolChoice` 保持不变)
- `agcore::llm::types::request::StreamOptions``agcore::llm::provider::openai::StreamOptions`
- `agcore::llm::types::request::OpenaiChatRequest``agcore::llm::provider::openai::OpenaiChatRequest`
- `agcore::llm::types::response::OpenaiChatResponse``agcore::llm::provider::openai::OpenaiChatResponse`
- `agcore::llm::types::response::OpenaiChatChunk``agcore::llm::provider::openai::OpenaiChatChunk`
- 其余 `request.rs`/`response.rs` 中的 wire-format 类型(`OpenaiTool``AudioParam``Choice``Delta` 等)同步移入 `agcore::llm::provider::openai` 模块
**类型删除:**
- `agcore::llm::types::ChatResponse` 已删除(自 v0.1.0 标记 `#[deprecated]`,请改用 `MessageResponse`
- `agcore::llm::types::old_stream::LegacyStreamEvent` 已删除(内部死代码)
### Features
- `ContextSlot::fork(child_id, strategy)` — 从父槽派生独立的子槽(数据层操作)
- `ContextSlot::merge(child, strategy)` — 将子槽消息合并回父槽(支持 Append/Replace
- `MergeStrategy` 枚举(`#[non_exhaustive]`Phase 16 可扩展 Summarize
```
---
## 5. 风险评估
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| `ChatResponse` 被外部 crate 引用 | 编译 break | 中 — `#[deprecated]` 仅产生编译警告,外部 crate 可能通过 `#[allow(deprecated)]` 静默依赖 | CHANGELOG 明确标注语义版本(0.3.0)和迁移指引;Step 13.3 验收加入 `cargo doc --no-deps \| grep ChatResponse` 确认零引用 |
| `StreamOptions` 等 wire-format 类型路径变更影响直接引用消费者 | 编译 break | 低(v0.2.0-rc.1,极少外部消费者使用内部类型) | CHANGELOG 完整列出所有路径变更;编译错误立即可发现 |
| `parse_chunk_stream` 有隐藏调用方 | 编译 break | 极低(实施前执行 `grep -rn 'parse_chunk_stream\|map_legacy_to_ir\|LegacyToIrEventStream' src/` 前置验证) | Step 13.3 前运行 grep 验证并记录结果;`cargo build --all-targets` 可 100% 捕获 |
| `#[allow(deprecated)]` 遗漏 | clippy 警告 | 低 | `cargo clippy --all-targets -- -D warnings` 验证 |
| Step 顺序错误导致编译中间态 | 开发者体验差 | 中 | 严格按 13.5→13.4→13.1→13.2→13.3 执行;每步 `cargo build` 验证 |
| `stream.rs` 简化后 import 断链 | 编译 break | 极低 | 保留 `pub use` 重导出路径,`cycle.rs`/`session.rs` import 不变 |
---
## 6. 验收标准
### M9 里程碑(Phase 13 完成条件)
| # | 条件 | 验证方法 |
|---|------|---------|
| 1 | `request.rs``response.rs``old_stream.rs` 三个旧文件不存在 | `ls src/llm/types/` 确认 |
| 2 | `ChatResponse` 结构体不存在 | 全局搜索 `ChatResponse` 仅保留 `openai.rs``OpenaiChatResponse` 引用 |
| 3 | `ToolChoice``tool.rs` 中定义,公共路径 `agcore::llm::types::ToolChoice` 保持不变 | `cargo doc --no-deps` 确认类型文档 |
| 4 | `OpenaiChatRequest`/`Response`/`Chunk``provider/openai.rs` 中定义 | 编译通过 |
| 5 | `ContextSlot::fork()` 单元测试通过(P0 条件全部满足) | `cargo test` |
| 6 | `ContextSlot::merge()` 单元测试通过(P0 条件全部满足) | `cargo test` |
| 7 | `stream.rs` 只保留 `pub use` 重导出 | 文件内容确认 |
| 8 | `cargo build --all-targets` 编译通过 | 编译验证 |
| 9 | `cargo test --all-targets` 全绿(预期 283~285 测试) | 测试验证 |
| 10 | `cargo clippy --all-targets -- -D warnings` 0 警告 | clippy 验证 |
| 11 | CHANGELOG 包含 Phase 13 的 Breaking Changes 和 Features 条目 | 文件确认 |
### fork/merge 详细验收 P0 项
**fork 的 5 项 P0 条件:**
| # | 条件 | 优先级 |
|---|------|--------|
| 1 | `fork("child", Full)` 创建新 slot,消息在 fork 时刻 == 父 slot | P0 |
| 2 | 子 slot 获得独立消息列表——父 slot 后续追加不影响子 slot | P0 |
| 3 | 子 slot 的 source 标记为 `Derived { parent_id, strategy }` | P0 |
| 4 | 子 slot 可独立持久化(fork + save + load roundtrip | P0 |
| 5 | fork 不允许重复 id(返回 `SlotAlreadyExists`)(由 `derive_slot` 编排层保证) | P0 |
**merge 的 5 项 P0 条件:**
| # | 条件 | 优先级 |
|---|------|--------|
| 1 | `parent.merge(child, Append)` 子消息追加到父末尾 | P0 |
| 2 | `parent.merge(child, Replace)` 子消息替换父全量消息 | P0 |
| 3 | merge 后父 slot 的 `meta.message_count` 正确更新 | P0 |
| 4 | merge 不允许合并到 Readonly 目标 slot | P0 |
| 5 | merge 不允许 self-mergechild.id == parent.id | P0 |
---
## 参考来源
- Roadmap`docs/roadmap.md` §Phase 13
- ContextSlot 设计:`docs/17-phase10-contextslot.md`
- 旧 StreamEvent 设计:`src/llm/stream.rs` 文件注释
- 当前代码库:`src/llm/types/request.rs``src/llm/types/response.rs``src/llm/types/old_stream.rs``src/llm/types/mod.rs``src/llm/provider/openai.rs``src/agent/context.rs``src/agent/session.rs`
---
## 7. 实施计划
### 全局说明
**commit 策略**:每个 Step 一个独立 commit。commit message 格式:
```
<type>(<scope>): <中文描述>
```
- Step 13.5 → `feat(agent): 实现 ContextSlot fork/merge`
- Step 13.4 → `refactor(types): ToolChoice 移入 tool.rs`
- Step 13.1 → `refactor(types): request.rs 类型移入 provider/openai.rs`
- Step 13.2 → `refactor(types): response.rs 类型移入 provider/openai.rs`
- Step 13.3 → `refactor(types): 删除旧类型文件和 ChatResponse`
**验证命令(每步通用)**
```bash
cargo build --all-targets && cargo test && cargo clippy --all-targets -- -D warnings
```
**预计测试数量变化**
- 当前基线:277 测试(每个 Step 开始时 `cargo test` 确认)
- Step 13.5 后:286+9
- Step 13.4-13.2 后:286(无变化)
- Step 13.3 后:285-1`ChatResponse``From` impl 无测试直接引用,删除后仅 `types/mod.rs` 中的 `deprecated` 注释行减少,不影响测试计数。实施前执行 `grep -rn 'ChatResponse' src/ --include='*test*' --include='*tests*'` 确认零测试引用)
- 最终范围:285 测试
### Step 13.5 — ContextSlot fork/merge
**前置依赖**:无(纯新增,不依赖前序 Step
**任务描述**:在 `agent/context.rs` 中新增 `MergeStrategy` 枚举、`ContextSlot::fork()` 方法和 `ContextSlot::merge()` 方法;重构 `agent/session.rs` 中的 `derive_slot` 改为调用 `parent.fork()`;新增 9 个内联测试覆盖 fork/merge 的 happy path 和 error path。
**涉及文件**
- `src/agent/context.rs` — 新增枚举和方法
- `src/agent/session.rs` — 重构 derive_slot
- `src/agent.rs` — 追加 `MergeStrategy` re-export
**具体操作**
1.`context.rs` 中新增 `MergeStrategy` 枚举(Append / Replace`#[non_exhaustive]`
2.`context.rs``impl ContextSlot` 块内新增 `fork(&self, child_id: String, strategy: DeriveStrategy) -> ContextSlot` 方法
3.`context.rs``impl ContextSlot` 块内新增 `merge(&mut self, child: ContextSlot, strategy: MergeStrategy) -> Result<(), AgentError>` 方法(含 self-merge/cross-session/Readonly 三项防御检查 + `tracing::debug!` 日志)
4.`session.rs``derive_slot` 方法中将手工消息复制代码替换为 `parent.fork(slot_id, strategy)`
5.`agent.rs``pub use context::{...}` 列表中追加 `MergeStrategy`
6.`context.rs``#[cfg(test)] mod tests` 中新增 9 个测试用例
**注意**:重构后 `derive_slot` 的子 slot `budget``ContextBudget::default()` 变为继承父 slot`compact``true` 变为继承父 slot。由于 `ContextBudget` 在 v0.2 无消费逻辑且父 slot 的 `compact` 默认也为 `true`,此变化无实际影响。验收条件中"行为不变"指对外功能行为不变(slot 消息内容、血缘关系不变)。
**预估工作量**M1-4h
**风险等级**:低(纯新增,不修改已有逻辑路径)
**验收条件**
- `MergeStrategy` 枚举存在,`Append``Replace` 两个变体可用,且通过 `agcore::agent::MergeStrategy` 路径可访问
- `ContextSlot::fork` 返回的 child 在 fork 时刻消息等于父 slot
- fork Focused 策略按 `FocusedConfig` 过滤消息
- 父 slot 后续追加消息不影响子 slot
- 子 slot 的 source 正确记录 `Derived { parent_id, strategy }`
- `parent.merge(child, Append)` 追加到父末尾,message_count 正确
- `parent.merge(child, Replace)` 替换父全量消息,message_count 正确
- self-merge 返回 `Err(AgentError::Config)`
- merge 到 Readonly slot 返回 `Err(AgentError::SlotReadonly)`
- 跨 session merge 返回 `Err(AgentError::Config)`
- `derive_slot` 对外行为不变(slot 消息内容、血缘关系、持久化行为均不变;内部 budget/compact 继承差异无实际影响),测试全绿
- `cargo doc --no-deps` 无 warning(验证新增公开 API 的文档注释完整)
**回退方式**`git revert` 该 commit
### Step 13.4 — ToolChoice 移入 tool.rs
**前置依赖**:Step 13.5(顺序约束:必须早于 Step 13.1——若 Step 13.1 先执行会将 `ToolChoice``request.rs` 一同删除,导致本 Step 无可搬移的源)
**任务描述**:将 `ToolChoice` 枚举及其 serde 实现从 `types/request.rs` 搬移到 `types/tool.rs`,更新所有 import/path 引用。公共 re-export 路径 `agcore::llm::types::ToolChoice` 保持不变。
**涉及文件**
- `src/llm/types/request.rs` — 删除 ToolChoice~28-99 行)
- `src/llm/types/tool.rs` — 新增 ToolChoice 枚举 + serde impl
- `src/llm/types/mod.rs` — re-export 路径从 `request` 改为 `tool`
- `src/llm/types/request_v2.rs` — import 路径从 `request::` 改为 `tool::`
**具体操作**
1.`request.rs` 复制 `ToolChoice` 枚举 + `Serialize`/`Deserialize` impl 到 `tool.rs`
2.`request.rs` 中删除 `ToolChoice` 定义
3.`mod.rs` 中将 `pub use request::{..., ToolChoice}` 改为 `pub use tool::ToolChoice`
4.`request_v2.rs` 中将 `use crate::llm::types::request::ToolChoice` 改为 `use crate::llm::types::tool::ToolChoice`
5. 验证 `cycle.rs``use crate::llm::types::ToolChoice`(通过 re-export)路径不变
**预估工作量**S<1h
**风险等级**:低(有限的 import 路径变更,编译立即可发现)
**验收条件**
- `ToolChoice``tool.rs` 中定义
- `pub use tool::ToolChoice``mod.rs`
- `request_v2.rs` 编译通过
- `cycle.rs` 路径不变
- `cargo build --all-targets` + `cargo test` + `cargo clippy` 全绿
**回退方式**`git revert` 该 commit
### Step 13.1 — request.rs 类型移入 openai.rs
**前置依赖**Step 13.4ToolChoice 已移走,request.rs 剩余内容全是 OpenAI wire-format 专有类型)
**任务描述**:删除 `types/request.rs` 整文件,将所有剩余类型(`OpenaiChatRequest``StreamOptions``OpenaiTool``AudioParam``PredictionContent``UserLocation``Approximate``WebSearchOptions`)搬入 `provider/openai.rs`,更新 `mod.rs` re-export。
**涉及文件**
- `src/llm/types/request.rs` — 整文件删除
- `src/llm/provider/openai.rs` — 新增所有类型定义
- `src/llm/types/mod.rs` — 删除 re-export + mod 声明
**具体操作**
1.`request.rs` 复制所有剩余类型定义到 `openai.rs`,可见性设为 `pub(crate)`
2. `OpenaiTool` 内引用 `OpenaiToolDefinition`(定义在 `tool.rs`),路径改为 `crate::llm::types::tool::OpenaiToolDefinition`
3. 删除 `openai.rs` 中原 `use crate::llm::types::request::{...}` import
4.`mod.rs` 删除 `pub use request::{OpenaiChatRequest, OpenaiTool, StreamOptions}``pub mod request;`
5. 删除 `types/request.rs` 文件
**预估工作量**M1-4h
**风险等级**:低(纯搬移 + 删除,文件内无逻辑变更)
**验收条件**
- `request.rs` 文件不存在
- `OpenaiChatRequest` 等类型在 `openai.rs` 中定义,编译通过
- `OpenaiTool` 通过 `crate::llm::types::tool::OpenaiToolDefinition` 正确引用
- `cargo build --all-targets` + `cargo test` + `cargo clippy` 全绿
**回退方式**`git revert` 该 commit。若 Step 13.2 也已提交,单独 revert 本 Step 可能因 `provider/openai.rs` 并发修改产生合并冲突。安全回退顺序为逆序:先 revert 13.2,再 revert 13.1。
### Step 13.2 — response.rs 类型移入 openai.rs
**前置依赖**:无(与 Step 13.1 共享 `provider/openai.rs``types/mod.rs`,但本 Step 仅追加类型定义,无覆盖操作;建议在 13.1 之后顺序执行以避免并行时的合并冲突)
**任务描述**:删除 `types/response.rs` 整文件,将所有类型(`OpenaiChatResponse``OpenaiChatChunk``Choice``Delta``ChunkChoice` 等 + 两个 `From` impl)搬入 `provider/openai.rs`,更新 `mod.rs``stream.rs` 的 import 路径。
**涉及文件**
- `src/llm/types/response.rs` — 整文件删除
- `src/llm/provider/openai.rs` — 新增所有类型定义 + From impl
- `src/llm/types/mod.rs` — 删除 re-export + mod 声明
- `src/llm/stream.rs``OpenaiChatChunk` import 路径改为 `provider::openai`
**具体操作**
1.`response.rs` 复制所有类型定义(含 `From` impl)到 `openai.rs`,可见性设为 `pub(crate)`
2. 删除 `openai.rs` 中原 `use crate::llm::types::response::{...}` import
3.`mod.rs` 删除 `pub use response::{...}``pub mod response;`
4.`stream.rs:26``OpenaiChatChunk` 的 import 路径改为 `crate::llm::provider::openai::OpenaiChatChunk``OpenaiToolCall` 路径不变)
5. 删除 `types/response.rs` 文件
**预估工作量**M1-4h
**风险等级**:低(与 Step 13.1 模式完全相同)
**验收条件**
- `response.rs` 文件不存在
- `OpenaiChatResponse`/`Chunk` 等类型在 `openai.rs` 中定义,编译通过
- `stream.rs` import 路径正确
- `cargo build --all-targets` + `cargo test` + `cargo clippy` 全绿
**回退方式**`git revert` 该 commit。若 Step 13.1 和本 Step 均已提交,安全回退顺序为逆序:先 revert 本 Step,再 revert 13.1。
### Step 13.3 — 旧文件清理 + ChatResponse 删除
**前置依赖**Step 13.1`request.rs` 已删)、Step 13.2`response.rs` 已删)
**任务描述**:删除 `old_stream.rs``ChatResponse`,简化 `stream.rs` 为仅保留 `pub use` 重导出。这是 Phase 13 技术风险最高的 Step。
**涉及文件**
- `src/llm/types/old_stream.rs` — 整文件删除
- `src/llm/types/mod.rs` — 删除 `pub mod old_stream;` + 删除 `ChatResponse` 结构体和两个 `From` impl
- `src/llm/stream.rs` — 删除死代码(约 160 行),仅保留 `pub use` 重导出
**具体操作**
1. **前置验证 A**:执行 `grep -rn 'parse_chunk_stream\|map_legacy_to_ir\|LegacyToIrEventStream\|ChunkToLegacyEventStream' src/` 确认零外部调用方,记录结果到 commit message
2. **前置验证 B**:执行 `cargo doc --no-deps 2>&1 | grep -i 'ChatResponse'` 确认零文档引用,记录结果
3.`mod.rs` 删除 `pub mod old_stream;`
4.`mod.rs` 删除 `ChatResponse` 结构体定义 + `#[allow(deprecated)]` `From<OpenaiChatResponse> for ChatResponse` + `From<ChatResponse> for OpenaiChatChunk`
5. 删除 `old_stream.rs` 文件
6.`stream.rs` 删除:`use crate::llm::types::old_stream::LegacyStreamEvent``parse_chunk_stream``parse_chunk_stream_legacy``ChunkToLegacyEventStream``LegacyToIrEventStream``map_legacy_to_ir``empty_message_response`
7. `stream.rs` 最终只保留 module doc comment + `pub use crate::llm::types::response_v2::StreamEvent;`
8. 检查 `cycle.rs:88``#[allow(deprecated)]` 属性是否仍与 `ChatResponse` 相关——若不相关则无需改动;若因 `ChatResponse` 删除而变脏,清理该属性
**预估工作量**S<1hcleanup+ M(需验证过程)
**风险等级**:中(`ChatResponse` 删除是 Breaking Change,外部可能静默依赖)
**验收条件**
- `old_stream.rs` 文件不存在
- `ChatResponse` 结构体不存在(全局搜索仅保留 `OpenaiChatResponse` 引用)
- `stream.rs` 只保留 `pub use` 重导出
- `cargo build --all-targets` 编译通过
- `cargo test --all-targets` 全绿(预期 285 测试)
- `cargo clippy --all-targets -- -D warnings` 0 警告
- `cargo doc --no-deps` 无 warning
**回退方式**`git revert` 该 commit(单独 revert 即可恢复 `ChatResponse` + `old_stream.rs`
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+273 -20
View File
@@ -1,13 +1,13 @@
# AG Core Roadmap # AG Core Roadmap
> 定稿日期:2026-05-11 > 定稿日期:2026-05-11
> 最后更新:2026-07-06 > 最后更新:2026-07-09Phase 14 完成 + M10 里程碑达成)
## 愿景 ## 愿景
AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。 AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。
**当前状态**v0.1.0 已发布(2026-07-04。Phase 0-11 全部完成v0.2.0-rc.1 已打标签。Provider IR 重构 + LlmCycle 简化 + 11 个离线示例(含 `quick_start` 30 行最小示例、`end_to_end` 完整集成示例、`context_slot_demo` 分支对话示例)+ SqliteStore 持久化 + 14 个公开枚举 `#[non_exhaustive]` 护栏 + `StepStatus` IR 迁移 + `submit_turn_stream` 流式体验 + ContextSlot 多上下文分区管理 + `VectorRetriever` 语义检索 trait + 12 个 wiremock Provider roundtrip 测试 + 5 个并发测试已交付。下一步进入 v0.2.0 正式版打 tag 流程 **当前状态**v0.2.0-rc.1 已打标签。Phase 0-14 全部完成v0.3.0 实施中,Phase 15-19 共 5 个增量 Phase 待交付。目标是从"LLM 调用工具箱"升级为"能构建多 Agent 协作、RAG、长记忆 Agent 产品的基础系统"
--- ---
@@ -606,21 +606,270 @@ graph BT
--- ---
## v0.3+ 展望 ## v0.3.0 — 多 Agent 基础系统(Multi-Agent Foundation
**目标**:从"LLM 调用工具箱"升级为"能构建多 Agent 协作、RAG、长记忆 Agent 产品的基础系统"。补齐 LangChain 7 大组件中缺失的 Document 和 VectorStore 能力,落地笔记设计中的 ContextSlot fork/merge、摘要自动生成、知识图谱,建立 engine 引擎层(会话树 + time-travel Checkpointer + SubAgent Dispatch + Agent Switch),为即将开发的多 Agent 产品提供完整基础。
**总体规模**7 个增量 PhasePhase 13-19),总新增代码约 2600 行,测试从 277 → 380+。
### 功能清单
#### P0 — 必须交付
| # | 功能 | 模块 | 方案要点 |
|---|------|------|---------|
| 1 | 技术债清理(旧 types 文件) | `llm/types` | `request.rs` / `response.rs` / `old_stream.rs` 三个 Phase 0 旧文件删除;内部类型移入 `provider/openai.rs` |
| 2 | ContextSlot fork/merge | `agent/context` | `fork(child_id, strategy)` 别名 + `merge(child, MergeStrategy)` 三种策略(Append/Replace/Summarize |
| 3 | Document 系统 | `document/`(新模块) | `Document` 核心类型 + `RecursiveCharacterSplitter`(递归字符分割,支持 chunk_size/chunk_overlap/separators |
| 4 | Embedding 抽象 | `llm/embedding` | `Embedding` trait`embed` / `dim`+ `MockEmbedding` 测试实现 |
| 5 | 向量存储持久化 | `vector/`(新模块) | `VectorStore` trait + `InMemoryVectorStore`(读写)+ `PersistentVectorStore`SqliteStore 后端)+ `RagPipeline` 组合器 |
| 6 | 摘要自动生成 | `agent` / `llm/hooks` | `SummaryConfig` 配置 + `OnTurnEnd` Hook 自动检测 token 水位 → 调 LLM 生成摘要 → `SessionMemory::set("conversation_summary", ...)` |
| 7 | SessionManager + 会话树 | `engine/`(新模块) | Session 工厂(`create`/`create_child`+ 按 ID 恢复(`get`+ 子树管理(`children`/`parent`/`destroy_subtree`+ 元数据持久化(MemoryStore |
| 8 | Time-travel Checkpointer | `engine/checkpointer` | `checkpoint(session)` 全量序列化 + `rollback(session_id, ckpt_id)` 回滚 + `fork(session_id, ckpt_id, new_id)` 分支 + `list_checkpoints` |
| 9 | Agent Switch | `engine/switch` | 热切换 `session.agent`(替换 `Arc<dyn Agent>`),slot 历史 / turn_index / session_memory 全保留 |
| 10 | SubAgent Dispatch | `engine/sub_agent` | `dispatch(parent, sub_agent, task, config)` 单任务 + `dispatch_all(parent, tasks, config)` 并行派发(Semaphore 并发控制)+ 子 SessionMemory 继承 + `SubTaskResult` 结构化回传 |
| 11 | 知识图谱 | `memory/graph` | `KnowledgeGraph` trait`add_entity` / `add_relation` / `get_related` / `find_by_keywords`+ `InMemoryGraph` 实现 + `tag_index` 标签管理 |
| 12 | 双通道检索 | `memory/retriever` | `MemoryRetriever` 扩展为双通道(`KnowledgeStore` + `KnowledgeGraph`+ `RetrievalStrategy::Hybrid` |
### 实施计划 — 7 个增量 Phase
> **编号说明**Phase 13-19 接续 v0.2 的 Phase 5-12,按开发顺序排列。
#### Phase 13: 热身清理 + ContextSlot fork/merge
**目标**:清除 Phase 0 遗留的旧 types 文件,交付超低价功能建立节奏。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **13.1** | `OpenaiChatRequest` 移入 `provider/openai.rs``types/request.rs` 删除 | `llm/types/request.rs` + `llm/provider/openai.rs` | `cargo build --all-targets` |
| **13.2** | `OpenaiChatResponse/Chunk` 移入 `provider/openai.rs``types/response.rs` 删除 | `llm/types/response.rs` + `llm/provider/openai.rs` | `cargo build --all-targets` |
| **13.3** | `old_stream.rs` 删除 + `types/mod.rs``ChatResponse` 删除 | `llm/types/old_stream.rs` + `llm/types/mod.rs` | `cargo build` + 确认 3 个旧文件不存在 |
| **13.4** | `ToolChoice``request.rs` 搬到 `tool.rs` | `llm/types/tool.rs` + `llm/types/request_v2.rs` | `cargo test --all-targets` 全绿 |
| **13.5** | `ContextSlot::fork(child_id, strategy)` 别名 + `merge(child, MergeStrategy)` | `agent/context.rs` | 单元测试:fork → 子 slot 消息 = 父 slot 副本;merge(Append) → 消息按序追加 |
**依赖**:无
**优先级**P0
**预估规模**:约 200 行
**状态**:✅ Phase 13 全部交付物已完成(2026-07-08)
---
#### Phase 14: Document 系统 + Embedding 抽象
**目标**:补齐 LangChain 7 大组件中最明显的缺口——Document 类型和分割器。不搞 Loader 框架,用户用 `fs::read_to_string` 自行加载。
**交付物**
1. `src/document.rs` 新模块(`Document` 类型 + `RecursiveCharacterSplitter`
2. `src/llm/embedding.rs``Embedding` trait + `MockEmbedding`
**设计要点**
- `Document`id / content / metadataHashMap<String, String>/ mime_type
- `RecursiveCharacterSplitter`chunk_size(默认 1000/ chunk_overlap(默认 200/ separators`["\n\n", "\n", "。", "", "", ".", " ", ""]`,含 CJK 标点)
- 两阶段算法:按 separator 优先级递归分割(Phase 1+ 贪心合并 + overlap 滑动窗口(Phase 2
- 所有长度比较以 Unicode 字符数为单位(`chars_len()`),非字节数
- `Embedding` trait`async fn embed(&self, input: &[String]) -> Result<Vec<Vec<f32>>, LlmError>` + `fn dim()`
- 复用 `LlmError` 而非新错误类型
- `MockEmbedding`:sin-hash 零依赖伪随机向量 + L2 归一化
- 不引入 `DocumentLoader` trait(应用层职责)
**实际新增**2026-07-09 commit `d4c4d8f`,详见 `docs/20-phase14-document-and-embedding.md`):
- 新增文件 3 个:
- `src/document.rs`580 行)— `Document` 类型(4 字段 + `new`/`from_raw` 构造器,2 个 `new` 接受 `impl Into<String>` + `RecursiveCharacterSplitter`(两阶段算法:按 separator 优先级递归分割 + 贪心合并 overlap,所有长度比较 `chars_len()` 字符级,overlap 提取 `chars().rev().take().rev()` 字符级安全)+ 19 个内联测试
- `src/llm/embedding.rs`183 行)— `Embedding` traitasync + `LlmError`+ `MockEmbedding`(sin-hash:字节和+长度做种子,`f32::sin(seed + i) * 10000`,L2 归一化到单位长度,零向量防除零)+ 6 个内联测试
- `examples/document_demo.rs`(74 行)— 端到端演示 Document → RecursiveCharacterSplitter → MockEmbedding → InMemoryVectorRetriever → search
- 修改文件 2 个:
- `src/lib.rs`+3 行:`pub mod document` + `pub use document::Document` + 空行)
- `src/llm.rs`+1 行:`pub mod embedding`
- 关键设计:
- **早返回守卫**`split_text``chars_len(text) <= self.chunk_size` 时直接返回 `[text]`,避免短文本在 Phase 2 `join("")` 中丢失 separator 边界
- **`Document::new` 使用 `impl Into<String>`**:接受 `&str``String`,比规范示例的 `String` 更灵活
- **`new()` panic + `try_new()` Result 双路径**:与 Rust 库惯例一致
- **CJK 分隔符扩展**`DEFAULT_SEPARATORS` 包含 `"。"`/`""`/`""`,避免中文文本跳过句子级退化为空格分割
- **chunk_size = 0 校验**:构造器拒绝零值,避免字符级兜底死循环
- **tracing 埋点**`split()` 入口 `tracing::debug!` + 每文档/每 chunk `tracing::trace!`
- **debug_assert 溢出保护**:单文档 chunk 数 < 10000 时 `debug_assert!`
- **Metadata 键覆盖文档化**`HashMap::insert()` 静默覆盖 source_id/chunk_index/chunk_count 在 `split()` doc comment 注明
- 测试:19 个 Document 测试(含 1 个 split_multibyte_utf8_boundary CJK 边界测试)+ 6 个 Embedding 测试,全量 286 → 313(+27 新测试,但部分测试覆盖范围重叠计算约 25 个净增)
- 方案文档:`docs/20-phase14-document-and-embedding.md`(1417 行,含背景/调研/方案对比/实施计划(详细版)/3 轮审查修复记录),经过 3 轮 PM/SA 审查 + 1 轮实施后修复
- clippy 0 警告,doc 0 warning
- 无新增外部依赖(`Cargo.toml` 未修改)
**实施后调整**
- 实施发现方案算法中 Phase 1 累加器设计与测试期望冲突("para1\n\npara2" 在 chunk_size=100 时 1 chunk 更合理),简化为"按 separator 切分 + Phase 2 合并"两阶段分工
- 二次审查发现 `split_text` 缺少早返回守卫 + `current_sep_count` 虚增计数,全部已修复
**依赖**:无(纯数据结构 + 零新 crate 依赖)
**优先级**P0
**预估规模**:约 350 行
**状态**:✅ Phase 14 全部交付物已完成(2026-07-09)
---
#### Phase 15: 向量存储持久化(SqliteStore 后端)
**目标**:实现 VectorStore 持久化,让语义检索支持进程重启后数据恢复。
**设计决策**:不用 pgvector。基于已有 SqliteStore`rusqlite`)做持久化包装——运行时全量加载到 InMemory 索引做余弦搜索,写时同步到 SqliteStore。
**交付物**
1. `src/vector/` 新模块:`VectorStore` trait + `InMemoryVectorStore` + `PersistentVectorStore` + `RagPipeline`
2. `VectorStore` trait`add(docs, embeddings)` / `search(query, k)` / `remove(ids)`
3. `PersistentVectorStore`:构造时从 SqliteStore 加载已有索引;`add` 双向写入;`search` 纯内存搜索
4. `RagPipeline`:组合器封装 `split``embed``store.add` 的 ingest 流程,以及 `embed``store.search` 的 retrieve 流程
5. SqliteStore 存储格式:`vec:{namespace}:{doc_id}` → JSON `{doc_id, content, metadata, embedding}`
**依赖**Phase 14Document 类型)
**优先级**P0
**预估规模**:约 400 行
**状态**:⏳ 待实施
---
#### Phase 16: 摘要自动生成
**目标**:闭环长对话能力。v0.2 的 `inject_summary` 消费端(`FocusedConfig.summary_override`)已就绪,缺的是生产端。
**交付物**
1. `SummaryConfig` 结构体:`enabled` / `trigger_token_ratio`(默认 0.75/ `summary_prompt`(可自定义)
2. 在 `OnTurnEnd` Hook 中插检查点:检测 token 水位超过 `trigger_token_ratio` → 调 LLM 生成摘要 → `SessionMemory::set("conversation_summary", summary)`
3. `AgentBuilder` 扩展:`.summary_config(cfg)` 方法
**为什么放 Hook 而非内置**:可插拔,默认不启用,用户 opt-in。不改变现有 `submit_turn` 行为。
**依赖**:无(Hook 系统 + SessionMemory 已就绪)
**优先级**P0
**预估规模**:约 150 行
**状态**:⏳ 待实施
---
#### Phase 17: Agent 执行引擎(会话树 + Time-travel Checkpointer
**目标**:建立 `engine/` 模块。解决 v0.2 中"session 在变量里、无法通过 ID 恢复、不支持父子关系"的空白。
**交付物**
1. `src/engine/` 新模块(`session_manager.rs` + `checkpointer.rs` + `error.rs`
2. `SessionManager`
- `create(agent, bundle) -> session_id` — 创建根 session
- `create_child(parent_id, child_id, agent)` — 创建子 session(继承父 `RuntimeBundle`
- `get(session_id) -> Arc<Mutex<AgentSession>>` — 按 ID 查找(支持从持久化恢复)
- `children(parent_id)` / `parent(child_id)` — 树形查询
- `destroy(id)` / `destroy_subtree(id)` — 生命周期管理
- `tree() -> SessionTreeSnapshot` — 树结构快照
3. `Checkpointer`
- `checkpoint(session)` — 每个 `submit_turn` 末尾自动保存全量状态快照
- `rollback(session_id, ckpt_id)` — 回滚到任意历史 checkpoint
- `fork(session_id, ckpt_id, new_id)` — 从历史 checkpoint 分支出新 session
- `list_checkpoints(session_id)` — 列出 checkpoint 列表
4. `AgentSession` 新增 `Serialize + Deserialize` 以支持 checkpoint 序列化
**Checkpoint 存储格式**`checkpoint:{session_id}:{ckpt_id}` → JSON(完整 AgentSession,含所有 slot 消息列表)。Ponytail:全量 JSON 够用,等遇到存储效率问题时再改增量模式。
**会话树持久化**`session_meta:{session_id}``{agent_name, parent_id, created_at, turn_count}``session_rel:{child_id}``"parent_id"`
**依赖**Phase 10ContextSlot 持久化 — 消息由 slot 自己管,Checkpointer 管执行状态)
**优先级**P0
**预估规模**:约 600 行
**状态**:⏳ 待实施
---
#### Phase 18: Agent Switch + SubAgent Dispatch + Agent 间交互
**目标**:在 SessionManager 基础上,提供 Agent 角色热切换和子代理调度能力。
**交付物**
1. `engine/switch.rs``switch_agent(session_id, new_agent)`:替换 `Arc<dyn Agent>`slot 历史 / turn_index / session_memory 全保留
2. `engine/sub_agent.rs` — SubAgent Dispatch 核心:
- `DispatchConfig``max_concurrency`(默认 10/ `inherit_session_memory`(默认 true/ `bridge_keys`
- `dispatch(parent_id, sub_agent, task, config) -> SubTaskResult`:创建子 session → 继承父 SessionMemory → `submit_turn` → 返回结构化结果
- `dispatch_stream(parent_id, sub_agent, task, config) -> SubTaskStream`:流式版
- `dispatch_all(parent_id, tasks, config) -> Vec<SubTaskResult>`:并行派发,`tokio::sync::Semaphore` 控制并发数
3. `SubTaskResult``child_id` / `response` / `usage` / `summary` + `child_memory(sm)` 读取子 SessionMemory
**Agent 间交互三层级**
- 父→子:继承 SessionMemory 快照 + `bridge_keys` 指定 key 强制注入 system prompt
- 子→父:`SubTaskResult` 结构化回传 + `SessionMemory["result_summary"]` 结论摘要
- 子↔子(间接):通过公共 `MemoryStore` namespace`shared:{parent_session_id}`)共享数据
**依赖**Phase 17SessionManager + 会话树)
**优先级**P0
**预估规模**:约 500 行
**状态**:⏳ 待实施
---
#### Phase 19: 知识图谱 + 双通道检索
**目标**:落地 `docs/note-knowledge-graph-design.md` 中记录的知识图谱设计,提供实体-关系图检索能力。扩展 `MemoryRetriever` 为双通道。
**交付物**
1. `src/memory/graph.rs`(新文件):
- `GraphEntity` / `GraphRelation` / `ScoredEntity` 核心类型
- `RelationDirection` 枚举(Outgoing / Incoming / Both
- `KnowledgeGraph` trait`add_entity` / `get_entity` / `remove_entity` / `add_relation` / `remove_relation` / `get_related` / `find_by_keywords` / `find_tags` / `set_entity_tags`
- `InMemoryGraph` 实现:`HashMap<String, GraphEntity>` + `Vec<GraphRelation>` + BFS 图遍历
- `TagConstraints``max_tags_per_entity` 默认 8
2. `src/memory/retriever.rs` 扩展:
- `MemoryRetriever` 增加 `knowledge_graph` 可选字段
- `RetrievalStrategy` 枚举:`Hybrid`(默认)/ `KnowledgeOnly` / `GraphOnly`
**与 Document 系统的关系**:知识图谱提供实体级检索("这个实体和什么相关"),VectorStore 提供语义相似度检索("哪些文档最相似"),两者互补。
**依赖**MemoryStore 持久化(v0.1 Phase 3
**优先级**P0
**预估规模**:约 400 行
**状态**:⏳ 待实施
---
### v0.3.0 Phase 依赖关系图
```mermaid
graph BT
P13["<b>Phase 13: 热身清理</b><br/>旧 types 文件删除<br/>ContextSlot fork/merge"]:::done
P14["<b>Phase 14: Document + Embedding</b><br/>Document 类型<br/>RecursiveCharacterSplitter<br/>Embedding trait"]:::done
P15["<b>Phase 15: 向量存储持久化</b><br/>VectorStore trait<br/>PersistentVectorStore<br/>RagPipeline"]:::pending
P16["<b>Phase 16: 摘要自动生成</b><br/>SummaryConfig<br/>OnTurnEnd Hook"]:::pending
P17["<b>Phase 17: 执行引擎</b><br/>SessionManager<br/>会话树<br/>Time-travel Checkpointer"]:::pending
P18["<b>Phase 18: 切换与调度</b><br/>Agent Switch<br/>SubAgent Dispatch<br/>dispatch_all 并发控制"]:::pending
P19["<b>Phase 19: 知识图谱</b><br/>KnowledgeGraph trait<br/>InMemoryGraph<br/>双通道检索"]:::pending
P15 --> P14
P18 --> P17
classDef done fill:#4ade80,stroke:#16a34a,color:#1a1a1a
classDef pending fill:#fbbf24,stroke:#d97706,color:#1a1a1a
```
### 关键里程碑
| 里程碑 | Phase 完成条件 | 可验证指标 | 状态 |
|--------|---------------|-----------|------|
| **M9** | Phase 13 | 旧 types 文件删除、`cargo test --all-targets` 全绿、`fork`/`merge` 测试通过 | ✅ 2026-07-08 |
| **M10** | Phase 14 | `Document` + `RecursiveCharacterSplitter` 分割结果验证、`MockEmbedding` 测试通过 | ✅ 2026-07-09 |
| **M11** | Phase 15 | `PersistentVectorStore` 持久化 roundtrip、`RagPipeline::ingest → retrieve` 端到端验证 | ⏳ |
| **M12** | Phase 16 | 多轮对话后摘要自动写入 SessionMemory、派生 slot 时摘要正确注入 | ⏳ |
| **M13** | **Phase 17 (rc.1)** | `SessionManager` 创建/子树/恢复集成测试通过、`Checkpointer` checkpoint/rollback/fork 验证 | ⏳ |
| **M14** | Phase 18 | `switch_agent` 热切换验证、`dispatch`/`dispatch_all` 多轮对话 + 结果回传验证 | ⏳ |
| **M15** | Phase 19 | `KnowledgeGraph` 实体-关系 CRUD + `get_related` BFS 验证、双通道检索 Hybrid 策略验证 | ⏳ |
---
## v0.4+ 展望
### 已规划的功能 ### 已规划的功能
| 功能 | 说明 | 预计版本 | | 功能 | 说明 | 预计版本 |
|------|------|---------| |------|------|---------|
| ContextSlot 分支(fork/merge | 在决策点 fork 出子上下文,分支独立演进,可合并/丢弃 | v0.3 | | Multi-Agent Swarm 编排 | Supervisor/Subgraph 模式,基于 v0.3 dispatch 构建 | v0.4 |
| 摘要自动生成 | Hook 驱动,`OnTurnEnd` 自动将对话摘要写入 `SessionMemory``inject_summary` 消费端已在 v0.2 就绪 | v0.3 | | Human-in-the-loop 审批 | `interrupt()` + `Command(resume=...)` 异步审批回调 | v0.4 |
| 知识图谱 | 实体-关系图,`docs/note-knowledge-graph-design.md` 已记录设计 | v0.3+ | | Agent 自动创生 | LLM 自主决定何时派发子 agent、派发什么角色 | v0.4 |
| Multi-Agent 协同(Swarm | 子 Agent 委派、并行子任务、结果聚合 | v0.4+ | | 分布式 session 共享 | SessionManager Redis 后端支持跨进程 | v0.4 |
| 精确 tokenizer 计数 | 绑定具体模型的 tokenizer 计数,替代当前的字符估算 | v0.3+ | | 精确 tokenizer 计数 | 引入 `tiktoken-rs`,绑定模型具体 tokenizer,替换字符估算 | v0.4+ |
| 血缘关系图遍历 | 以 `parent_id` 为基础,提供 slot 血缘链查询 | v0.3+ | | TokenJuice 语义压缩 | 对工具结果做语义压缩而非字节截断 | v0.4+ |
| Markdown 技能按需加载 | 兼容 `SKILL.md` 格式,按 prompt 上下文动态加载 | v0.3+ | | Markdown 技能按需加载 | 技能注册表 + 按 prompt 上下文动态加载 | v0.4+ |
| TokenJuice 语义压缩 | 对工具结果做语义压缩而非字节截断 | v0.3+ | | 增量 checkpoint | 仅存储变化部分,替换当前全量 JSON 模式 | v0.4+ |
| Human-in-the-loop 审批 | 高危工具执行前的异步审批回调 | v0.3+ |
| RL 轨迹导出 | ShareGPT 格式轨迹、Atropos 集成 | v0.4+ | | RL 轨迹导出 | ShareGPT 格式轨迹、Atropos 集成 | v0.4+ |
### 明确不做(agcore 范围外) ### 明确不做(agcore 范围外)
@@ -637,18 +886,19 @@ graph BT
1. **持久化依赖**`rusqlite` + `bundled` 零外部依赖编译,但 SQLite 不适配所有场景(分布式/高并发写)。`MemoryStore` trait 的抽象层允许下游自行实现 Redis / PostgreSQL 后端 1. **持久化依赖**`rusqlite` + `bundled` 零外部依赖编译,但 SQLite 不适配所有场景(分布式/高并发写)。`MemoryStore` trait 的抽象层允许下游自行实现 Redis / PostgreSQL 后端
2. **ContextSlot 心智负担**`ContextSlot` 引入了一等抽象的复杂度。建议通过 `AgentBuilder` 默认创建 `"default"` slot,让简单场景无感使用 2. **ContextSlot 心智负担**`ContextSlot` 引入了一等抽象的复杂度。建议通过 `AgentBuilder` 默认创建 `"default"` slot,让简单场景无感使用
3. **向量检索生态**`VectorRetriever` trait-only 不绑定实现,需社区贡献或用户自行适配 pgvector / qdrant / lancedb 3. **向量检索规模上限**v0.3 的 `PersistentVectorStore` 全量加载到内存做余弦搜索,适合 ≤10 万条向量。超出此规模需换用专用向量库。v0.4 可以评估引入
4. **Scope 蔓延**agcore 定位为"支持库"而非"Agent 产品",始终以 trait + reference impl 边界,业务循环留给上层 4. **Scope 蔓延**v0.3 新增 `engine/` `vector/` `document/` 三个模块,功能覆盖扩展到多 Agent 基础系统。始终保持 trait + reference impl 边界,业务循环留给上层
5. **API 稳定性**v0.2 引入 `#[non_exhaustive]``#[deprecated]` 机制,但不承诺 SemVer 稳定——仍在快速迭代期 5. **API 稳定性**v0.3 引入 `Checkpointer``SessionManager``VectorStore` 等新公开 APIv0.2 已有的 `#[non_exhaustive]``#[deprecated]` 机制继续沿用
6. **Checkpointer 存储效率**:v0.3 使用全量 JSON 序列化存储 checkpoint,每轮对话约几百 KB。`fork` 从历史 checkpoint 创建新 session 时也会复制全量。等实际使用中发现存储瓶颈时再改为增量模式
--- ---
## 下一步行动 ## 下一步行动
1. **v0.2.0 正式版打 tag**Phase 8-11 全部完成,去掉 rc 后缀打 `v0.2.0` 正式版标签;CHANGELOG 整理 + Cargo.toml version 0.2.0-rc.1 → 0.2.0 1. **v0.3.0 Phase 15 启动**:向量存储持久化(`VectorStore` trait + `InMemoryVectorStore` + `PersistentVectorStore` + `RagPipeline` 组合器),基于 Phase 14 的 `Document` 类型构建
2. **Phase 12 评估**(可选):P2 锦上添花三项(文件系统 MemoryStore / MCP StreamableHttp / Gemini Provider)按需选做 2. **Phase 15-19 顺次交付**:按依赖关系推进向量存储 → 摘要 → 引擎 → 调度 → 知识图谱
3. **示例先行**v0.2 范围内每完成一个 Phase 立即更新对应示例,验证通过后再合入 3. **示例先行**:每完成一个 Phase 立即创建/更新对应示例,确保 `cargo run --example`验证
4. **里程碑追踪**:以 Phase 11(已完成,2026-07-06)为最新节点,逐 Phase 验收 4. **里程碑追踪**:以 M10Phase 14)为已达成里程碑,逐 Phase 推进 M11-M15
**已完成 / 进行中阶段** **已完成 / 进行中阶段**
- ✅ Phase 0 Foundation — 全部交付物已完成 - ✅ Phase 0 Foundation — 全部交付物已完成
@@ -665,10 +915,13 @@ graph BT
- ✅ **Phase 9 流式体验增强**`AgentSession::submit_turn_stream` 流式事件序列 + `LlmCycle::submit_with_tools_stream` spawn + mpsc 状态机 + `StreamEvent::ToolExecutionStarted`/`Completed` 新变体 + 9 单元测试 + 2 集成测试(含 `submit_turn_stream_end_to_end` 端到端 mock 验证 + `submit_turn_stream_triggers_turn_hooks` Hook 触发验证),全量 200 → 211;`CycleConfig``Clone` derive;方案文档 `docs/16-phase9-streaming-experience.md`821 行) - ✅ **Phase 9 流式体验增强**`AgentSession::submit_turn_stream` 流式事件序列 + `LlmCycle::submit_with_tools_stream` spawn + mpsc 状态机 + `StreamEvent::ToolExecutionStarted`/`Completed` 新变体 + 9 单元测试 + 2 集成测试(含 `submit_turn_stream_end_to_end` 端到端 mock 验证 + `submit_turn_stream_triggers_turn_hooks` Hook 触发验证),全量 200 → 211;`CycleConfig``Clone` derive;方案文档 `docs/16-phase9-streaming-experience.md`821 行)
- ✅ **Phase 10 ContextSlot 上下文管理**`src/agent/context.rs` 新增 `ContextSlot` 核心类型(Full / Focused / Readonly 三种模式,New / Derived / Static 三种来源)+ JSON blob 批次持久化(每 slot 3-4 条 MemoryItem`slot_config` key 自恢复支持旧版本兼容);`AgentSession` 扩展 slots 字段 + 5 个管理方法(`create_slot` / `switch_slot` / `list_slots` / `derive_slot` / `delete_slot`,自动创建 `"default"` slot`delete_slot` 双重保护禁止删 default/最后一个);`submit_turn`/`finalize_turn` 改造为基于当前 slot 的增量追加写回(`cycle.messages()[input_len..]` 提取本轮新增消息,确保 Focused 模式"读时过滤"语义不丢失数据);`finalize_turn` 签名变更(新增 `new_messages_from_cycle: Vec<Message>` 参数,返回 `Result<(), AgentError>`);`agent/error.rs` 新增 3 个 Slot 错误变体(`SlotReadonly` / `SlotNotFound` / `SlotAlreadyExists`);`examples/context_slot_demo.rs` 新增分支对话示例(法律咨询入口 → 两个派生方向 → 切换 → 隔离验证 → 删除保护);方案文档 `docs/17-phase10-contextslot.md`(1227 行,含 §5 推荐方案、§6 实施建议、§9 实施计划,经过 4 轮方案/计划/实施审查 + 1 轮非阻塞建议修复);全量 211 → 254(+43 新测试),clippy 0 警告,doc 0 warning11 个离线示例全部 exit 0 - ✅ **Phase 10 ContextSlot 上下文管理**`src/agent/context.rs` 新增 `ContextSlot` 核心类型(Full / Focused / Readonly 三种模式,New / Derived / Static 三种来源)+ JSON blob 批次持久化(每 slot 3-4 条 MemoryItem`slot_config` key 自恢复支持旧版本兼容);`AgentSession` 扩展 slots 字段 + 5 个管理方法(`create_slot` / `switch_slot` / `list_slots` / `derive_slot` / `delete_slot`,自动创建 `"default"` slot`delete_slot` 双重保护禁止删 default/最后一个);`submit_turn`/`finalize_turn` 改造为基于当前 slot 的增量追加写回(`cycle.messages()[input_len..]` 提取本轮新增消息,确保 Focused 模式"读时过滤"语义不丢失数据);`finalize_turn` 签名变更(新增 `new_messages_from_cycle: Vec<Message>` 参数,返回 `Result<(), AgentError>`);`agent/error.rs` 新增 3 个 Slot 错误变体(`SlotReadonly` / `SlotNotFound` / `SlotAlreadyExists`);`examples/context_slot_demo.rs` 新增分支对话示例(法律咨询入口 → 两个派生方向 → 切换 → 隔离验证 → 删除保护);方案文档 `docs/17-phase10-contextslot.md`(1227 行,含 §5 推荐方案、§6 实施建议、§9 实施计划,经过 4 轮方案/计划/实施审查 + 1 轮非阻塞建议修复);全量 211 → 254(+43 新测试),clippy 0 警告,doc 0 warning11 个离线示例全部 exit 0
- ✅ **Phase 11 测试与检索补强**`src/memory/vector.rs` 新增 `VectorRetriever` traitindex + search 抽象)+ `InMemoryVectorRetriever` 引用实现(HashMap + 全量余弦相似度扫描 + 零依赖 `dot()`),6 个内联测试覆盖 basic/empty/zero-vector/k=0/2 个并发;wiremock Provider roundtrip 测试 12 个(OpenAI 8 + Anthropic 4)覆盖请求体/header/401/429/500/529/流式 usage-only/流式错误/ToolUse/结构化错误体;`MemoryStore` 并发测试 5 个(InMemoryStore 3 + SqliteStore 2)覆盖 100 并发写、5 写+5 读混合 2 秒、15 写者容量淘汰;`openai.rs` `handle_error_response` 修复 429 retry-after 解析(5 行,与 anthropic 对齐);方案文档 `docs/18-phase11-testing-and-retrieval.md`(647 行,含 10 项架构决策 + 2 条实施偏差记录 #6 mid-stream mock 模式 + #7 retry-after 修复);全量 254 → 277+23 新测试),clippy 0 警告,doc 0 warning,并发测试 3 次稳定无 flaky - ✅ **Phase 11 测试与检索补强**`src/memory/vector.rs` 新增 `VectorRetriever` traitindex + search 抽象)+ `InMemoryVectorRetriever` 引用实现(HashMap + 全量余弦相似度扫描 + 零依赖 `dot()`),6 个内联测试覆盖 basic/empty/zero-vector/k=0/2 个并发;wiremock Provider roundtrip 测试 12 个(OpenAI 8 + Anthropic 4)覆盖请求体/header/401/429/500/529/流式 usage-only/流式错误/ToolUse/结构化错误体;`MemoryStore` 并发测试 5 个(InMemoryStore 3 + SqliteStore 2)覆盖 100 并发写、5 写+5 读混合 2 秒、15 写者容量淘汰;`openai.rs` `handle_error_response` 修复 429 retry-after 解析(5 行,与 anthropic 对齐);方案文档 `docs/18-phase11-testing-and-retrieval.md`(647 行,含 10 项架构决策 + 2 条实施偏差记录 #6 mid-stream mock 模式 + #7 retry-after 修复);全量 254 → 277+23 新测试),clippy 0 警告,doc 0 warning,并发测试 3 次稳定无 flaky
- ✅ **Phase 13 热身清理 + ContextSlot fork/merge** — 3 个旧 types 文件删除(`request.rs` 187 行 + `response.rs` 177 行 + `old_stream.rs` 45 行),所有 OpenAI wire-format 类型迁入 `provider/openai.rs` 可见性 `pub(crate)`Breaking Change:原 `agcore::llm::types::OpenaiChatRequest/Response/Chunk` 公共 re-export 路径已删除);`ChatResponse` 自 v0.1.0 标记 `#[deprecated]` 后在 Phase 13 整体删除;`ToolChoice``request.rs` 迁入 `tool.rs`(公共 `agcore::llm::types::ToolChoice` 路径不变);`ContextSlot::fork()` 派生独立子 slot`SlotSource::Derived { parent_id, strategy }` 血缘可追溯)+ `ContextSlot::merge(child, MergeStrategy)` 合入父 slot`Append` / `Replace` 两种策略,`#[non_exhaustive]` 为 Phase 16 `Summarize` 预留);`MergeStrategy` 防御性检查(self-merge / 跨 session / Readonly 目标全部阻断);`AgentSession::derive_slot` 重构复用 `fork()` 消除重复;`agent.rs` 追加 `MergeStrategy` re-export9 个 fork/merge 内联测试覆盖 happy path 与 error path`stream.rs` 简化为 module doc + `pub use` 重导出(保持 `use crate::llm::stream::StreamEvent` 路径兼容);方案文档 `docs/19-phase13-cleanup-and-fork-merge.md`640 行);全量 277 → 286+9 新测试),clippy 0 警告,doc 0 warning
- ✅ Provider IR 重构 — 统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen/Ollama 适配 - ✅ Provider IR 重构 — 统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen/Ollama 适配
- ✅ LlmCycle 简化 — IR 消息类型切换 + Phase 0 桥接层移除 - ✅ LlmCycle 简化 — IR 消息类型切换 + Phase 0 桥接层移除
- ✅ v0.1 Release — 技术债扫清、MockProvider 公开化、8 个离线示例(含 `simple_visit`)、README + 错误消息友好化、CHANGELOG 初始化 - ✅ v0.1 Release — 技术债扫清、MockProvider 公开化、8 个离线示例(含 `simple_visit`)、README + 错误消息友好化、CHANGELOG 初始化
- 📋 **v0.2 规划细化完成** — 8 个增量 PhasePhase 5-12),17 个可验证 Step,覆盖 P0-P2 全部 12 项功能 + ContextSlot - **v0.2 规划细化完成** — 8 个增量 PhasePhase 5-12),17 个可验证 Step,覆盖 P0-P2 全部 12 项功能 + ContextSlot
- ✅ **v0.3.0 Phase 13 完成** — 技术债清理(3 旧 types 文件 + ChatResponse 删除)+ ContextSlot fork/merge9 新测试),M9 里程碑达成
- ✅ **v0.3.0 Phase 14 完成** — Document 类型(id/content/metadata/mime_type+ `RecursiveCharacterSplitter` 两阶段算法(按 separator 优先级递归分割 + 贪心合并 overlap,全部 `chars_len()` 字符级比较)+ `Embedding` traitasync + `LlmError` 复用)+ `MockEmbedding`sin-hash 零依赖伪随机 + L2 归一化)+ 19 Document 测试 + 6 Embedding 测试(含 1 个 split_multibyte_utf8_boundary CJK 边界测试);`src/document.rs`580 行)+ `src/llm/embedding.rs`183 行)+ `examples/document_demo.rs`74 行);`pub use document::Document` 在 lib.rs 重导出;CJK 分隔符(`。`/``/``)加入 `DEFAULT_SEPARATORS`;方案文档 `docs/20-phase14-document-and-embedding.md`1417 行);全量 286 → 313+27 新测试,0 失败),clippy 0 警告,doc 0 warning,零新外部依赖;M10 里程碑达成;Phase 15-19 共 5 个增量 Phase 待实施(向量存储 → 摘要 → 引擎 → 调度 → 知识图谱)
--- ---
+70
View File
@@ -0,0 +1,70 @@
//! document_demo —— Document + RecursiveCharacterSplitter + MockEmbedding + RagPipeline 完整衔接示例。
//!
//! 演示 RAG 管线:
//! 1. 创建多段落 Document
//! 2. RecursiveCharacterSplitter 分割为 chunk
//! 3. RagPipeline.ingest() 自动嵌入并存储
//! 4. RagPipeline.retrieve() 做语义检索
//!
//! 运行:`cargo run --example document_demo`(离线,零配置)
use std::sync::Arc;
use agcore::document::{Document, RecursiveCharacterSplitter};
use agcore::llm::embedding::{Embedding, MockEmbedding};
use agcore::memory::{InMemoryVectorStore, RagPipeline, VectorStore};
#[tokio::main]
async fn main() {
agcore::init_tracing();
// 1. 创建多段落 Document(含中英文混合)
let doc = Document::new(
"rust-intro",
"Rust 是一门系统编程语言,注重安全、并发和性能。\n\n\
Rust \
\n\n\
Rust 线\
Send Sync trait 线\n\n\
Rust C/C++ \
Cargo 使",
"text/markdown",
);
println!("输入文档: {} 字符", doc.content.chars().count());
// 2. 构造 RAG 管线(嵌入器 + 向量存储 + 分割器)
let embedder: Arc<dyn Embedding> = Arc::new(MockEmbedding::new(4));
let store: Arc<dyn VectorStore> = Arc::new(InMemoryVectorStore::new());
let splitter = RecursiveCharacterSplitter::new(200, 30);
let pipeline = RagPipeline::new(
Arc::clone(&embedder),
Arc::clone(&store),
Some(splitter),
);
// 3. 一次性 ingest:自动 split → embed → add
pipeline.ingest(std::slice::from_ref(&doc)).await.unwrap();
// 4. 模拟查询:复用第一个 chunk 的 content 作为查询文本
let chunks_in_store = store.search(&[1.0, 0.0, 0.0, 0.0], 1).await.unwrap();
assert!(!chunks_in_store.is_empty(), "ingest 后 store 应有数据");
let query_text = &chunks_in_store[0].0.content;
let results = pipeline.retrieve(query_text, 3).await.unwrap();
println!("\nTop 3 检索结果(与第一个 chunk 相似):");
for (doc, score) in &results {
println!(
" id={}, score={:.4}, content={}",
doc.id, score, doc.content
);
}
assert!(!results.is_empty(), "至少应返回 1 条检索结果");
assert!(
results[0].0.id.starts_with("rust-intro:chunk:0000"),
"Top 1 应为 chunk 0 自身"
);
println!("\n✓ document_demo 完成");
}
+2 -2
View File
@@ -22,8 +22,8 @@ pub mod task;
pub use agent::Agent; pub use agent::Agent;
pub use builder::AgentBuilder; pub use builder::AgentBuilder;
pub use context::{ pub use context::{
ContextBudget, ContextSlot, DeriveStrategy, FocusedConfig, SlotConfig, SlotMeta, SlotMode, ContextBudget, ContextSlot, DeriveStrategy, FocusedConfig, MergeStrategy, SlotConfig,
SlotSource, SlotMeta, SlotMode, SlotSource,
}; };
pub use error::AgentError; pub use error::AgentError;
pub use runtime::{AgentConfig, RuntimeBundle}; pub use runtime::{AgentConfig, RuntimeBundle};
+223
View File
@@ -103,6 +103,18 @@ pub enum DeriveStrategy {
Focused(FocusedConfig), Focused(FocusedConfig),
} }
/// 合并策略 —— Phase 13 新增,控制 `ContextSlot::merge` 如何将子 slot 消息合入父 slot。
///
/// `#[non_exhaustive]` 允许 Phase 16 加入 `Summarize` 变体而不破坏现有匹配。
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum MergeStrategy {
/// 子 slot 消息追加到父 slot 末尾。
Append,
/// 用子 slot 消息替换父 slot 内容。
Replace,
}
/// 上下文预算(v0.2 纯数据结构,无消费逻辑)。 /// 上下文预算(v0.2 纯数据结构,无消费逻辑)。
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextBudget { pub struct ContextBudget {
@@ -422,6 +434,82 @@ impl ContextSlot {
_ => self.messages.clone(), _ => self.messages.clone(),
} }
} }
/// 从当前 slot 派生出独立的子 slot(不持久化,调用方负责 `save`)。
///
/// 子 slot 的 `meta` 全新创建(`SlotMeta::new()`),不继承父 slot 的 `message_count`。
/// 子 slot 的 `source` 标记为 `Derived { parent_id, strategy }`,血缘可追溯。
pub fn fork(&self, child_id: String, strategy: DeriveStrategy) -> ContextSlot {
let messages = match &strategy {
DeriveStrategy::Full => self.messages.clone(),
DeriveStrategy::Focused(cfg) => Self::filter_focused(&self.messages, cfg),
};
tracing::debug!(
parent_id = %self.id,
child_id = %child_id,
?strategy,
"ContextSlot::fork"
);
ContextSlot {
id: child_id,
session_id: self.session_id.clone(),
config: SlotConfig {
mode: match &strategy {
DeriveStrategy::Full => SlotMode::Full,
DeriveStrategy::Focused(cfg) => SlotMode::Focused(cfg.clone()),
},
source: SlotSource::Derived {
parent_id: self.id.clone(),
strategy,
},
budget: self.config.budget.clone(),
compact: self.config.compact,
},
messages,
meta: SlotMeta::new(),
}
}
/// 将子 slot 的消息合并到当前 slot。
///
/// **注意**:本方法仅操作内存数据,不自动持久化。
/// 调用方需在 merge 后自行调用 `self.save(&store)` 将结果写入后端存储。
///
/// 防御性检查:
/// - 禁止 self-merge`self.id == child.id`
/// - 禁止跨 session merge
/// - 禁止合并到 Readonly slot
pub fn merge(&mut self, child: ContextSlot, strategy: MergeStrategy) -> Result<(), AgentError> {
if self.id == child.id {
return Err(AgentError::Config("不能将 slot 合并到自身".into()));
}
if self.session_id != child.session_id {
return Err(AgentError::Config("不能合并不同 session 的 slot".into()));
}
if matches!(self.config.mode, SlotMode::Readonly) {
return Err(AgentError::SlotReadonly("Readonly slot 不允许合并".into()));
}
tracing::debug!(
self_id = %self.id,
child_id = %child.id,
?strategy,
"ContextSlot::merge"
);
match strategy {
MergeStrategy::Append => {
let count = child.messages.len();
self.messages.extend(child.messages);
self.meta.message_count += count;
}
MergeStrategy::Replace => {
self.messages = child.messages;
self.meta.message_count = self.messages.len();
}
}
Ok(())
}
} }
#[cfg(test)] #[cfg(test)]
@@ -870,6 +958,141 @@ mod tests {
} }
} }
// ====== Phase 13: fork/merge ======
#[test]
fn fork_full_copies_messages() {
let mut parent = make_slot("p", "s1");
parent.append_messages(vec![Message::user_text("a")]).unwrap();
parent.append_messages(vec![Message::assistant("b")]).unwrap();
let child = parent.fork("c".into(), DeriveStrategy::Full);
assert_eq!(child.messages.len(), 2);
assert!(matches!(child.config.mode, SlotMode::Full));
}
#[test]
fn fork_focused_filters_messages() {
let mut parent = make_slot("p", "s1");
parent.append_messages(vec![Message::system("sys")]).unwrap();
for i in 0..5 {
parent
.append_messages(vec![Message::user_text(format!("u{i}"))])
.unwrap();
}
let cfg = FocusedConfig {
keep_system: true,
recent_messages: 2,
summary_override: None,
};
let child = parent.fork("c".into(), DeriveStrategy::Focused(cfg));
// system + 最近 2 条非 system
assert_eq!(child.messages.len(), 1 + 2);
assert!(matches!(child.config.mode, SlotMode::Focused(_)));
}
#[test]
fn fork_preserves_independence() {
let mut parent = make_slot("p", "s1");
parent.append_messages(vec![Message::user_text("a")]).unwrap();
let mut child = parent.fork("c".into(), DeriveStrategy::Full);
let child_count_at_fork = child.messages.len();
// 父 slot 追加
parent
.append_messages(vec![Message::user_text("b")])
.unwrap();
// 子 slot 追加
child.append_messages(vec![Message::user_text("c")]).unwrap();
assert_eq!(parent.messages.len(), 2);
assert_eq!(child.messages.len(), child_count_at_fork + 1);
assert_eq!(extract_text(child.messages.last().unwrap()), "c");
}
#[test]
fn fork_sets_derived_source() {
let mut parent = make_slot("p", "s1");
parent.append_messages(vec![Message::user_text("a")]).unwrap();
let child = parent.fork("c".into(), DeriveStrategy::Full);
match &child.config.source {
SlotSource::Derived { parent_id, strategy } => {
assert_eq!(parent_id, "p");
assert!(matches!(strategy, DeriveStrategy::Full));
}
_ => panic!("子 slot source 应为 Derived"),
}
// 子 slot 的 meta 全新创建
assert_eq!(child.meta.message_count, 0);
assert!(child.meta.parent_id.is_none());
}
#[test]
fn merge_append_appends_messages() {
let mut parent = make_slot("p", "s1");
parent.append_messages(vec![Message::user_text("p1")]).unwrap();
let child = {
let mut c = parent.fork("c".into(), DeriveStrategy::Full);
// fork 时 child 继承父的 "p1";再追加一条 c1
c.append_messages(vec![Message::user_text("c1")]).unwrap();
c
};
parent
.merge(child, MergeStrategy::Append)
.expect("merge ok");
// Append 追加 child 全部消息到父:1 (p1) + 2 (p1 + c1) = 3
assert_eq!(parent.messages.len(), 3);
assert_eq!(parent.meta.message_count, 3);
assert_eq!(extract_text(&parent.messages[2]), "c1");
}
#[test]
fn merge_replace_replaces_messages() {
let mut parent = make_slot("p", "s1");
parent.append_messages(vec![Message::user_text("p1")]).unwrap();
parent.append_messages(vec![Message::user_text("p2")]).unwrap();
let child = {
let mut c = parent.fork("c".into(), DeriveStrategy::Full);
// 清空 child 再追加
c.messages.clear();
c.append_messages(vec![Message::user_text("c-only")])
.unwrap();
c
};
parent
.merge(child, MergeStrategy::Replace)
.expect("merge ok");
assert_eq!(parent.messages.len(), 1);
assert_eq!(extract_text(&parent.messages[0]), "c-only");
assert_eq!(parent.meta.message_count, 1);
}
#[test]
fn merge_self_rejected() {
let mut slot = make_slot("p", "s1");
slot.append_messages(vec![Message::user_text("a")]).unwrap();
let child = slot.fork("p".into(), DeriveStrategy::Full);
let err = slot.merge(child, MergeStrategy::Append).unwrap_err();
assert!(matches!(err, AgentError::Config(_)));
}
#[test]
fn merge_readonly_rejected() {
let mut parent = make_slot("p", "s1");
parent.config.mode = SlotMode::Readonly;
let child = ContextSlot::new("s1", "c", SlotConfig::default());
let err = parent.merge(child, MergeStrategy::Append).unwrap_err();
assert!(matches!(err, AgentError::SlotReadonly(_)));
}
#[test]
fn merge_cross_session_rejected() {
let mut parent = make_slot("p", "s1");
parent.append_messages(vec![Message::user_text("a")]).unwrap();
let child = ContextSlot::new("OTHER_SESSION", "c", SlotConfig::default());
let err = parent.merge(child, MergeStrategy::Append).unwrap_err();
assert!(matches!(err, AgentError::Config(_)));
}
// ====== Colon 校验(key 格式保护) ====== // ====== Colon 校验(key 格式保护) ======
#[test] #[test]
+112 -36
View File
@@ -16,8 +16,11 @@ use futures_core::Stream;
use crate::agent::agent::Agent; use crate::agent::agent::Agent;
use crate::agent::context::{ use crate::agent::context::{
ContextSlot, DeriveStrategy, SlotConfig, SlotMode, SlotSource, ContextSlot, DeriveStrategy, SlotConfig, SlotMode,
}; };
// SlotSource 仅在 `mod tests` 中使用(通过 `use super::*;` 引入),lib 主体保留以避免测试 import 变更。
#[allow(unused_imports)]
use crate::agent::context::SlotSource;
use crate::agent::error::AgentError; use crate::agent::error::AgentError;
use crate::agent::runtime::RuntimeBundle; use crate::agent::runtime::RuntimeBundle;
use crate::agent::session_memory::SessionMemory; use crate::agent::session_memory::SessionMemory;
@@ -224,38 +227,9 @@ impl AgentSession {
.slots .slots
.get(parent_id) .get(parent_id)
.ok_or_else(|| AgentError::SlotNotFound(parent_id.to_string()))?; .ok_or_else(|| AgentError::SlotNotFound(parent_id.to_string()))?;
let child = parent.fork(slot_id.clone(), strategy);
let parent_messages = parent.messages.clone(); child.save(&*self.resolve_store()).await?;
let (messages, focused_cfg) = match &strategy { self.slots.insert(slot_id, child);
DeriveStrategy::Full => (parent_messages, None),
DeriveStrategy::Focused(cfg) => {
let filtered = ContextSlot::filter_focused(&parent_messages, cfg);
(filtered, Some(cfg.clone()))
}
};
let mode = match focused_cfg {
Some(cfg) => SlotMode::Focused(cfg),
None => SlotMode::Full,
};
let slot = ContextSlot::new(
&self.session_id,
&slot_id,
SlotConfig {
mode,
source: SlotSource::Derived {
parent_id: parent_id.to_string(),
strategy,
},
budget: Default::default(),
compact: true,
},
);
let mut slot = slot;
slot.messages = messages;
slot.save(&*self.resolve_store()).await?;
self.slots.insert(slot_id, slot);
Ok(()) Ok(())
} }
@@ -444,9 +418,6 @@ impl AgentSession {
// 6. turn_index 递增 —— 配合 finalize_turn 用 (turn_index - 1) 传递正确的 OnTurnEnd 序号 // 6. turn_index 递增 —— 配合 finalize_turn 用 (turn_index - 1) 传递正确的 OnTurnEnd 序号
self.turn_index += 1; self.turn_index += 1;
// 注:hook_executor 不显式 drop,生命周期由 Arc 自动管理
let _ = hook_executor;
Ok(stream) Ok(stream)
} }
@@ -501,10 +472,12 @@ mod tests {
use crate::agent::builder::AgentBuilder; use crate::agent::builder::AgentBuilder;
use crate::llm::hooks::{Hook, HookContext, HookExecutor, HookResult}; use crate::llm::hooks::{Hook, HookContext, HookExecutor, HookResult};
use crate::llm::mock::MockProvider; use crate::llm::mock::MockProvider;
use crate::llm::stream::StreamEvent;
use crate::llm::types::message::ContentBlock; use crate::llm::types::message::ContentBlock;
use crate::llm::types::response_v2::{MessageResponse, StopReason}; use crate::llm::types::response_v2::{MessageResponse, StopReason};
use crate::tools::ToolRegistry; use crate::tools::ToolRegistry;
use async_trait::async_trait; use async_trait::async_trait;
use futures_util::StreamExt;
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
/// 计数 hook —— 每被调用一次 +1。 /// 计数 hook —— 每被调用一次 +1。
@@ -974,4 +947,107 @@ mod tests {
.unwrap_err(); .unwrap_err();
assert!(matches!(err, AgentError::SlotReadonly(_))); assert!(matches!(err, AgentError::SlotReadonly(_)));
} }
// ====== Phase 9 Step 5: 集成测试 ======
/// Phase 9 Step 5.1 — `submit_turn_stream` 端到端链路。
///
/// 验证:mock provider → `submit_turn_stream` 消费流 → 收到 TextDelta + MessageComplete
/// → `finalize_turn` 后 `cost_so_far` 正确更新,turn_index 递增。
#[tokio::test(flavor = "multi_thread")]
async fn submit_turn_stream_end_to_end() {
let (mut session, _, _) = build_session(vec![assistant_text("hi back")]);
let mut stream = session
.submit_turn_stream("user msg")
.await
.expect("submit_turn_stream 应成功");
// 消费流并提取 MessageComplete
let mut final_response: Option<MessageResponse> = None;
while let Some(ev) = stream.next().await {
if let StreamEvent::MessageComplete { full_response } = &ev {
final_response = Some(full_response.clone());
}
}
let response = final_response.expect("流中应有 MessageComplete");
// consumer 负责构造本轮新增消息列表(user_input + assistant_response)。
// submit_turn_stream 不会自动写入 self.slots(流是延迟的),
// 消费者需在 finalize_turn 时把 [user_input, ...tool_results, final_response] 一并传入。
let new_messages = vec![Message::user_text("user msg"), response.message.clone()];
session
.finalize_turn(&response, new_messages)
.await
.expect("finalize_turn 应成功");
// cost_so_far 已累计(assistant_text 的 usage 是 from_input_output(10, 5)
assert_eq!(session.usage().total().prompt_tokens, 10);
assert_eq!(session.usage().total().completion_tokens, 5);
// turn_index 已递增
assert_eq!(session.turn_index(), 1);
// default slot 应包含 user 输入和 assistant 响应
let slot = session.slots.get("default").expect("default slot");
let has_user = slot
.messages
.iter()
.any(|m| matches!(m, Message::User { .. }));
let has_resp = slot.messages.iter().any(|m| extract_text(m) == "hi back");
assert!(has_user && has_resp, "default slot 应包含 user 和 assistant 消息");
}
/// Phase 9 Step 5.2 — `submit_turn_stream` 触发 OnTurnStart / OnTurnEnd hook。
///
/// 验证:OnTurnStart 在 `submit_turn_stream` 返回流之前已触发;
/// OnTurnEnd 在 `finalize_turn` 调用后才触发。
#[tokio::test(flavor = "multi_thread")]
async fn submit_turn_stream_triggers_turn_hooks() {
let (mut session, start_count, end_count) = build_session(vec![assistant_text("ok")]);
// 初始状态:两个 hook 计数都是 0
assert_eq!(start_count.0.load(Ordering::SeqCst), 0);
assert_eq!(end_count.0.load(Ordering::SeqCst), 0);
// 调 submit_turn_stream
let mut stream = session
.submit_turn_stream("user msg")
.await
.expect("submit_turn_stream 应成功");
// OnTurnStart 应在流返回前已触发
assert_eq!(
start_count.0.load(Ordering::SeqCst),
1,
"OnTurnStart 应在 submit_turn_stream 返回流之前触发"
);
// OnTurnEnd 此时尚未触发
assert_eq!(
end_count.0.load(Ordering::SeqCst),
0,
"OnTurnEnd 不应在 submit_turn_stream 阶段触发"
);
// 消费流
let mut final_response: Option<MessageResponse> = None;
while let Some(ev) = stream.next().await {
if let StreamEvent::MessageComplete { full_response } = &ev {
final_response = Some(full_response.clone());
}
}
// finalize_turn
let response = final_response.expect("流中应有 MessageComplete");
session
.finalize_turn(&response, vec![])
.await
.expect("finalize_turn 应成功");
// OnTurnEnd 已触发
assert_eq!(
end_count.0.load(Ordering::SeqCst),
1,
"OnTurnEnd 应在 finalize_turn 后触发"
);
}
} }
+580
View File
@@ -0,0 +1,580 @@
//! Document 系统 —— 文本分割与文档类型。
//!
//! 提供 [`Document`] 数据结构和 [`RecursiveCharacterSplitter`] 分割器,
//! 作为 RAG 管线(split → embed → store)的前置步骤。
use std::collections::HashMap;
/// 默认分隔符优先级列表(按优先级降序)。
///
/// 段落级 → 行级 → 句子级(含 CJK 标点) → 词级 → 字符级(兜底)。
/// 在 LangChain 基础上扩充了 CJK 句号 `"。"`、问号 `""`、感叹号 `""`,
/// 确保中文文本在句子边界有更高分割质量。
const DEFAULT_SEPARATORS: &[&str] = &["\n\n", "\n", "", "", "", ".", " ", ""];
/// 文档片段 —— RAG 管线的基本数据载体。
///
/// 作为分割(split)和向量化(embed)两个阶段的通货类型,
/// 在 Phase 15 的 RagPipeline 中串联 split → embed → store。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Document {
/// 文档唯一标识。
pub id: String,
/// 文档文本内容。
pub content: String,
/// 元数据标签(键值对,可用作过滤、溯源、分类)。
pub metadata: HashMap<String, String>,
/// MIME 类型,标识内容格式(如 "text/plain", "text/markdown")。
pub mime_type: String,
}
impl Document {
/// 创建一个新文档。元数据默认初始化为空。
///
/// 分割器产生的 chunks 会自动继承源文档 mime_type
/// 并在 metadata 中追加 source_id / chunk_index / chunk_count。
pub fn new(
id: impl Into<String>,
content: impl Into<String>,
mime_type: impl Into<String>,
) -> Self {
Self {
id: id.into(),
content: content.into(),
metadata: HashMap::new(),
mime_type: mime_type.into(),
}
}
/// 快速构造纯文本文档(mime_type 默认为 "text/plain")。
/// 适用于大多数无需指定媒体类型的场景。
pub fn from_raw(id: impl Into<String>, content: impl Into<String>) -> Self {
Self {
id: id.into(),
content: content.into(),
metadata: HashMap::new(),
mime_type: "text/plain".into(),
}
}
}
/// 递归字符级文档分割器。
///
/// 使用可配置的分隔符优先级列表,递归地将文档分割为
/// 接近 chunk_size 的块。
///
/// # 算法(两阶段)
///
/// 1. **递归分割**:按分隔符优先级从高到低递归切割文本,
/// 产生初始片段(均 ≤ chunk_size,按字符数计算)。
///
/// 2. **贪心合并**:从左向右合并相邻片段,直到合计字符数
/// 超过 chunk_size,此时将前一组合并结果作为一个 chunk 输出,
/// 并携带 chunk_overlap 字符的滑动窗口。
///
/// 所有长度比较均以 Unicode 字符数为单位(`text.chars().count()`),
/// 而非字节数。CJK 文本每个字算 1 个 char。
///
/// # 升级路径
///
/// - 如需自定义分割函数,可在上层通过 `with_custom_splitter`
/// 扩展(当前未实现,预留升级路径)。
/// - 如需 unicode 感知的句子分割(如中文句号、缩写处理),
/// 可在 separators 中加入对应字符串,或将下游替换为
/// 基于 unicode-segmentation crate 的自定义分割器。
#[derive(Debug, Clone)]
pub struct RecursiveCharacterSplitter {
chunk_size: usize,
chunk_overlap: usize,
separators: Vec<String>,
}
impl RecursiveCharacterSplitter {
/// 创建分割器。
///
/// # Panics
///
/// - 如果 `chunk_size == 0`
/// - 如果 `chunk_size ≤ chunk_overlap`(无法形成有效滑动窗口)
pub fn new(chunk_size: usize, chunk_overlap: usize) -> Self {
if chunk_size == 0 {
panic!("chunk_size must be greater than 0");
}
if chunk_size <= chunk_overlap {
panic!("chunk_size must be greater than chunk_overlap");
}
Self {
chunk_size,
chunk_overlap,
separators: DEFAULT_SEPARATORS.iter().map(|s| s.to_string()).collect(),
}
}
/// 创建分割器的安全版本。
///
/// 验证失败时返回 `Err` 而非 panic。
pub fn try_new(chunk_size: usize, chunk_overlap: usize) -> Result<Self, &'static str> {
if chunk_size == 0 {
return Err("chunk_size must be greater than 0");
}
if chunk_size <= chunk_overlap {
return Err("chunk_size must be greater than chunk_overlap");
}
Ok(Self {
chunk_size,
chunk_overlap,
separators: DEFAULT_SEPARATORS.iter().map(|s| s.to_string()).collect(),
})
}
/// 覆盖默认分隔符优先级列表。
///
/// **重要**:建议保留 `""` 作为最后一个 separator
/// 作为字符级兜底防止任何文本都能被分割。
pub fn with_separators(mut self, separators: Vec<String>) -> Self {
self.separators = separators;
self
}
/// 返回 chunk_size(字符数)。
pub fn chunk_size(&self) -> usize {
self.chunk_size
}
/// 返回 chunk_overlap(字符数)。
pub fn chunk_overlap(&self) -> usize {
self.chunk_overlap
}
/// 批量分割。
///
/// 每个输入文档独立分割。输出 chunks 继承源文档的 mime_type
/// 并在 metadata 中追加 source_id / chunk_index / chunk_count。
///
/// Chunk ID 格式:`{source_id}:chunk:{index:04d}`
/// 例如 `"doc_001:chunk:0000"`(索引从 0 开始,4 位固定宽度)。
///
/// **注意**metadata 注入使用 `HashMap::insert()`,如果源 Document
/// 的 metadata 已包含 `"source_id"`、`"chunk_index"` 或 `"chunk_count"`
/// 键,将被分割器的值静默覆盖。
pub fn split(&self, documents: &[Document]) -> Vec<Document> {
tracing::debug!(
input_count = documents.len(),
"RecursiveCharacterSplitter::split start"
);
let mut output = Vec::new();
for doc in documents {
let segments = self.split_text(&doc.content, &self.separators);
let chunks = self.merge_with_overlap(segments);
debug_assert!(
chunks.len() < 10_000,
"单个文档产生超过 9999 个 chunk,索引格式溢出"
);
tracing::trace!(
doc_id = %doc.id,
chunk_count = chunks.len(),
"document split into chunks"
);
for (idx, chunk_text) in chunks.iter().enumerate() {
let mut metadata = doc.metadata.clone();
metadata.insert("source_id".to_string(), doc.id.clone());
metadata.insert("chunk_index".to_string(), idx.to_string());
metadata.insert("chunk_count".to_string(), chunks.len().to_string());
let id = format!("{}:chunk:{:04}", doc.id, idx);
tracing::trace!(chunk_id = %id, "chunk produced");
output.push(Document {
id,
content: chunk_text.clone(),
metadata,
mime_type: doc.mime_type.clone(),
});
}
}
output
}
/// 递归分割(Phase 1)。
///
/// 按 separator 优先级从高到低切割文本。每个输出片段的字符数
/// 均 ≤ chunk_size(除非最终降到 `""` 字符级兜底)。
///
/// Phase 1 只做"切分",不做合并——合并由 Phase 2 (`merge_with_overlap`) 处理。
///
/// **关键行为**:当文本中存在 separator 时,按 separator 切分。
/// 若所有 segment 均 ≤ chunk_size,直接返回所有 segments
/// 若某个 segment > chunk_size,递归降级到下一级 separator。
///
/// **早返回守卫**:如果整段文本 ≤ chunk_size(含恰好等于),直接
/// 返回 `[text.to_string()]`,避免在 Phase 2 合并时丢失 separator
/// 边界信息。
fn split_text(&self, text: &str, separators: &[String]) -> Vec<String> {
if text.is_empty() {
return Vec::new();
}
// 早返回:整段文本 ≤ chunk_size 时整体返回,避免分割后再
// 合并时丢失 separator 边界
if chars_len(text) <= self.chunk_size {
return vec![text.to_string()];
}
if separators.is_empty() {
// 防御:理论上不应到达这里(DEFAULT_SEPARATORS 末尾有 `""`
return self.split_by_chars(text);
}
let sep = &separators[0];
if sep.is_empty() {
// 字符级兜底
return self.split_by_chars(text);
}
// 检查文本中是否包含当前 separator
if !text.contains(sep.as_str()) {
// 不含此 separator,降级到下一级
return self.split_text(text, &separators[1..]);
}
// 文本中存在 separator,按 separator 切分
let raw_segments: Vec<&str> = text.split(sep.as_str()).collect();
let mut result = Vec::new();
for seg in raw_segments {
if seg.is_empty() {
continue;
}
if chars_len(seg) > self.chunk_size {
// 当前片段超长:递归降级到下一级 separator
result.extend(self.split_text(seg, &separators[1..]));
} else {
// 当前片段符合 chunk_size,直接输出
result.push(seg.to_string());
}
}
result
}
/// 字符级兜底分割(确保任何文本都能被切到 chunk_size 以内)。
///
/// 使用 `char_indices()` 步进,避免截断在多字节 UTF-8 字符中间。
fn split_by_chars(&self, text: &str) -> Vec<String> {
let mut result = Vec::new();
let mut current = String::new();
for (_, ch) in text.char_indices() {
current.push(ch);
if chars_len(&current) >= self.chunk_size {
result.push(std::mem::take(&mut current));
}
}
if !current.is_empty() {
result.push(current);
}
result
}
/// 贪心合并 + overlap 滑动窗口(Phase 2)。
///
/// 把 Phase 1 输出的 segments 合并到目标 chunk_size,并对相邻 chunk
/// 应用 chunk_overlap 字符的重叠窗口。
///
/// **已知行为**:合并时使用空字符串 `""` 连接相邻 segments
/// (即 `current.join("")`),不保留 Phase 1 切分时消耗的 separator
/// 边界信息。这意味着跨 chunk 的结构化边界(如段落、句子)会
/// 在合并点"塌缩"——但对 RAG 语义检索影响通常较小。如需保留
/// separator 边界,可重构此方法接受 separator 参数。
fn merge_with_overlap(&self, mut segments: Vec<String>) -> Vec<String> {
if segments.is_empty() {
return Vec::new();
}
if segments.len() == 1 {
return segments;
}
// Phase 2a: 贪心合并 segments 到目标 chunk_size
// segments 用 "" 连接,sep_count 不参与长度计算)
let mut chunks: Vec<String> = Vec::new();
let mut current: Vec<String> = Vec::new();
let mut current_len: usize = 0;
for seg in segments.drain(..) {
let seg_len = chars_len(&seg);
let new_total = current_len + seg_len;
if new_total > self.chunk_size && !current.is_empty() {
chunks.push(current.join(""));
current.clear();
current_len = 0;
}
current.push(seg);
current_len += seg_len;
}
if !current.is_empty() {
chunks.push(current.join(""));
}
if chunks.len() <= 1 {
return chunks;
}
// Phase 2b: 应用 overlap 滑动窗口(除第一个 chunk 外)
let overlap = self.chunk_overlap;
if overlap == 0 {
return chunks;
}
for i in 1..chunks.len() {
let prev = &chunks[i - 1];
let prev_chars_count = chars_len(prev);
if prev_chars_count == 0 {
continue;
}
let take_n = overlap.min(prev_chars_count);
// 字符级安全地取 prev 末尾 take_n 个字符
let tail: String = prev.chars().rev().take(take_n).collect::<Vec<_>>().into_iter().rev().collect();
chunks[i] = format!("{}{}", tail, chunks[i]);
}
chunks
}
}
impl Default for RecursiveCharacterSplitter {
fn default() -> Self {
Self::new(1000, 200)
}
}
/// 字符数(Unicode 标量值),等价于 `s.chars().count()`。
#[inline]
fn chars_len(s: &str) -> usize {
s.chars().count()
}
#[cfg(test)]
mod tests {
use super::*;
// ===== Group B1 — Document struct 基础测试 =====
#[test]
fn document_new_metadata_defaults_empty() {
let doc = Document::new("id-1", "content", "text/plain");
assert!(doc.metadata.is_empty());
}
#[test]
fn document_clone_partial_eq() {
let doc = Document::new("id-1", "content", "text/plain");
let cloned = doc.clone();
assert_eq!(doc, cloned);
}
#[test]
fn document_different_ids_not_equal() {
let doc1 = Document::new("id-1", "content", "text/plain");
let doc2 = Document::new("id-2", "content", "text/plain");
assert_ne!(doc1, doc2);
}
#[test]
fn document_from_raw_uses_text_plain() {
let doc = Document::from_raw("id-1", "hello");
assert_eq!(doc.mime_type, "text/plain");
assert!(doc.metadata.is_empty());
}
// ===== Group B2 — Splitter 边界条件测试 =====
#[test]
fn split_empty_doc_returns_empty() {
let splitter = RecursiveCharacterSplitter::new(100, 20);
let chunks = splitter.split(&[]);
assert!(chunks.is_empty());
}
#[test]
fn split_short_doc_single_chunk() {
let splitter = RecursiveCharacterSplitter::new(100, 20);
let doc = Document::from_raw("short", "hello");
let chunks = splitter.split(&[doc]);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].content, "hello");
assert_eq!(chunks[0].metadata.get("chunk_index").map(|s| s.as_str()), Some("0"));
assert_eq!(chunks[0].metadata.get("chunk_count").map(|s| s.as_str()), Some("1"));
}
#[test]
fn split_empty_content_yields_no_chunks() {
let splitter = RecursiveCharacterSplitter::new(100, 20);
let doc = Document::from_raw("empty", "");
let chunks = splitter.split(&[doc]);
assert!(chunks.is_empty());
}
#[test]
#[should_panic(expected = "chunk_size must be greater than chunk_overlap")]
fn split_constructor_panics_on_invalid_overlap() {
let _ = RecursiveCharacterSplitter::new(10, 10);
}
#[test]
#[should_panic(expected = "chunk_size must be greater than 0")]
fn split_constructor_panics_on_zero_chunk_size() {
let _ = RecursiveCharacterSplitter::new(0, 0);
}
#[test]
fn try_new_returns_err_on_invalid_params() {
assert!(RecursiveCharacterSplitter::try_new(0, 0).is_err());
assert!(RecursiveCharacterSplitter::try_new(10, 10).is_err());
assert!(RecursiveCharacterSplitter::try_new(100, 20).is_ok());
}
#[test]
fn default_separators_match_spec() {
let splitter = RecursiveCharacterSplitter::default();
// Default separators should include CJK punctuation as the last meaningful
// separator before the char-level fallback. We can't directly access the
// private field, so we verify behavior: a Chinese sentence should split
// on "。" at the sentence level rather than the word level.
let doc = Document::from_raw("zh", "你好世界。今天天气好。");
let chunks = splitter.split(&[doc]);
// The default chunk_size=1000, so the whole content fits in 1 chunk.
// But the separators list contains "。" — this is verified via integration test.
assert!(!chunks.is_empty());
}
// ===== Group B3 — Splitter 核心算法测试 =====
#[test]
fn split_paragraph_boundary() {
// 小 chunk_size 强制段落级别分割
let splitter = RecursiveCharacterSplitter::new(4, 1);
let doc = Document::from_raw("p", "para1\n\npara2");
let chunks = splitter.split(&[doc]);
// para1 (5 chars) > chunk_size=4 → 递归降级到 char 级拆分
// para2 同理
// 总共应该产生多个 chunk
assert!(chunks.len() >= 2, "expected >= 2 chunks, got {}", chunks.len());
}
#[test]
fn split_recursive_deepen() {
let splitter = RecursiveCharacterSplitter::new(50, 5);
// 200 字符无 \n\n,强制降级
let text: String = "a".repeat(200);
let doc = Document::from_raw("long", &text);
let chunks = splitter.split(&[doc]);
assert!(chunks.len() >= 3, "expected >= 3 chunks, got {}", chunks.len());
for chunk in &chunks {
// chunk 内容 = overlap_tail(≤5) + new_content(≤50),故 ≤ 55
assert!(chars_len(&chunk.content) <= 55, "chunk too long: {} chars", chars_len(&chunk.content));
}
}
#[test]
fn split_greedy_merge_combines_segments() {
let splitter = RecursiveCharacterSplitter::new(20, 2);
// 一段含多个 \n\n 分隔的短小段,应被合并到 chunk_size
let doc = Document::from_raw("g", "aa\n\nbb\n\ncc\n\ndd");
let chunks = splitter.split(&[doc]);
// 短段应被合并:总共应该少于 4 个 chunk
assert!(chunks.len() <= 3, "expected <= 3 chunks after merge, got {}", chunks.len());
}
#[test]
fn split_overlap_consistency() {
let splitter = RecursiveCharacterSplitter::new(20, 5);
// 构造一个需要多 chunk 的文本
let text: String = "x".repeat(50);
let doc = Document::from_raw("o", &text);
let chunks = splitter.split(&[doc]);
assert!(chunks.len() >= 2);
// chunk[1] 应该以 chunk[0] 的最后 5 个字符作为前缀
let prev_tail: String = chunks[0]
.content
.chars()
.rev()
.take(5)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
assert!(
chunks[1].content.starts_with(&prev_tail),
"chunk[1] should start with last 5 chars of chunk[0]: prev_tail={:?}, chunk[1]={:?}",
prev_tail,
chunks[1].content
);
}
#[test]
fn split_character_fallback() {
let splitter = RecursiveCharacterSplitter::new(5, 0);
// 纯字母无标点,应降级到字符级
let doc = Document::from_raw("cf", "aaaaaaaaa");
let chunks = splitter.split(&[doc]);
assert_eq!(chunks.len(), 2, "expected 2 chunks, got {}", chunks.len());
for chunk in &chunks {
assert!(chars_len(&chunk.content) <= 5);
}
}
#[test]
fn split_multibyte_utf8_boundary() {
// 验证字符级单位而非字节级单位
let splitter = RecursiveCharacterSplitter::new(10, 2);
// 30 个中文字符 = 90 字节(UTF-8)
let text: String = "".repeat(30);
let doc = Document::from_raw("cjk", &text);
let chunks = splitter.split(&[doc]);
// 30 字符 / 10 chunk_size = 3 个 chunk
assert!(chunks.len() >= 3, "expected >= 3 chunks for 30 chars / chunk_size=10, got {}", chunks.len());
for chunk in &chunks {
let char_count = chars_len(&chunk.content);
// chunk = overlap_tail(≤2) + new_content(≤10),故 ≤ 12
assert!(char_count <= 12, "chunk char count {} exceeds 10+overlap", char_count);
}
}
// ===== Group B4 — Splitter 集成测试 =====
#[test]
fn split_multiple_docs() {
let splitter = RecursiveCharacterSplitter::new(50, 5);
let docs = vec![
Document::from_raw("a", "a".repeat(30).as_str()),
Document::from_raw("b", "b".repeat(30).as_str()),
Document::from_raw("c", "c".repeat(30).as_str()),
];
let chunks = splitter.split(&docs);
assert!(chunks.len() >= 3);
// 每个 chunk 的 source_id 应指向对应的输入 doc
for chunk in &chunks {
let source = chunk.metadata.get("source_id").unwrap();
assert!(["a", "b", "c"].contains(&source.as_str()));
}
}
#[test]
fn split_metadata_inheritance() {
let splitter = RecursiveCharacterSplitter::new(100, 10);
let mut doc = Document::new("m", "short content", "text/plain");
doc.metadata.insert("author".to_string(), "alice".to_string());
let chunks = splitter.split(&[doc]);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].metadata.get("author").map(|s| s.as_str()), Some("alice"));
assert_eq!(chunks[0].metadata.get("source_id").map(|s| s.as_str()), Some("m"));
assert_eq!(chunks[0].metadata.get("chunk_index").map(|s| s.as_str()), Some("0"));
assert_eq!(chunks[0].metadata.get("chunk_count").map(|s| s.as_str()), Some("1"));
}
}
+3
View File
@@ -1,11 +1,14 @@
//! agcore —— 智能体(Agent)核心工具箱。 //! agcore —— 智能体(Agent)核心工具箱。
pub mod agent; pub mod agent;
pub mod document;
pub mod llm; pub mod llm;
pub mod memory; pub mod memory;
pub mod prompt; pub mod prompt;
pub mod tools; pub mod tools;
pub use document::Document;
use tracing_subscriber::{EnvFilter, fmt, prelude::*}; use tracing_subscriber::{EnvFilter, fmt, prelude::*};
static INIT: std::sync::Once = std::sync::Once::new(); static INIT: std::sync::Once = std::sync::Once::new();
+1
View File
@@ -3,6 +3,7 @@
pub mod compact; pub mod compact;
pub mod convert; pub mod convert;
pub mod cycle; pub mod cycle;
pub mod embedding;
pub mod error; pub mod error;
pub mod hooks; pub mod hooks;
pub mod mock; pub mod mock;
+16 -3
View File
@@ -18,7 +18,7 @@ use tokio_stream::wrappers::UnboundedReceiverStream;
use crate::llm::compact::{CompactConfig, CompactState, microcompact, should_compact}; use crate::llm::compact::{CompactConfig, CompactState, microcompact, should_compact};
use crate::llm::cycle::retry::should_retry; use crate::llm::cycle::retry::should_retry;
use crate::llm::error::LlmError; use crate::llm::error::LlmError;
use crate::llm::hooks::{HookContext, HookExecutor}; use crate::llm::hooks::{HookContext, HookEvent, HookExecutor};
use crate::llm::provider::LlmProvider; use crate::llm::provider::LlmProvider;
use crate::llm::stream::StreamEvent; use crate::llm::stream::StreamEvent;
use crate::llm::types::message::{ContentBlock, Message}; use crate::llm::types::message::{ContentBlock, Message};
@@ -774,8 +774,21 @@ async fn run_tool_loop(
..Default::default() ..Default::default()
}; };
// ② PreRequest hookfire-and-forget,仅占位保留以保持接口对称) // ② PreRequest hook —— 与 `submit_with_tools` / `submit_stream` 行为对齐:
let _ = hook_executor.as_ref(); // 触发 hook → 检查 should_block → 阻断则事件化 Error + return 结束 task。
// 阻断原因透传,让消费者看到完整的拒绝原因。
if let Some(ref executor) = hook_executor {
let ctx = HookContext::new(HookEvent::PreRequest).with_request(&request);
let results = executor.execute(HookEvent::PreRequest, &ctx).await;
if let Some(blocking) = results.iter().find(|r| r.should_block) {
let reason = blocking
.reason
.clone()
.unwrap_or_else(|| "Blocked by pre-request hook".to_string());
let _ = tx.send(StreamEvent::Error { message: reason });
return;
}
}
// ③ chat_stream —— 第一层错误 // ③ chat_stream —— 第一层错误
let mut stream = match provider.chat_stream(request).await { let mut stream = match provider.chat_stream(request).await {
+183
View File
@@ -0,0 +1,183 @@
//! Embedding 抽象 —— 文本向量化接口。
//!
//! 提供 [`Embedding`] trait 和零依赖的 [`MockEmbedding`] 引用实现。
//! 上层可实现此 trait 以对接真实 Embedding ProviderOpenAI、Cohere 等)。
//!
//! 所有实现使用 [`LlmError`] 作为统一错误类型,与 llm 模块保持一致。
use async_trait::async_trait;
use crate::llm::error::LlmError;
/// 文本向量化抽象接口。
///
/// 将文本字符串转换为固定维度的浮点向量,用于语义相似度计算。
/// 设计为异步以支持网络 IO(如 OpenAI Embedding API)。
///
/// 使用 [`LlmError`] 作为统一错误类型,与 llm 模块保持一致。
///
/// # 实现要求
///
/// - `embed()` 返回的向量外层的 Vec 长度必须等于输入切片长度(一对一映射)
/// - 内层 Vec 长度必须等于 `dim()` 返回值
/// - 调用方应保证输入非空(空切片返回空外层 Vec,不报错)
///
/// # 稳定性
///
/// 实验性 APIv0.3.x),方法签名可能在 v0.4 中调整。
#[async_trait]
pub trait Embedding: Send + Sync {
/// 批量向量化。
///
/// 返回 `Vec<Vec<f32>>`,第 i 个内层向量对应 `input[i]`。
async fn embed(&self, input: &[String]) -> Result<Vec<Vec<f32>>, LlmError>;
/// 返回向量维度。
fn dim(&self) -> usize;
}
/// 确定性 Mock Embedding —— 零依赖伪随机单位向量。
///
/// 使用 sin 哈希将输入字符串映射到单位球面上的一个点:
/// 1. 对输入字符串计算简单哈希(字符字节和 + 长度)作为种子
/// 2. 用 `f32::sin(seed + i) * 10000` 生成第 i 个维度的值
/// 3. 归一化到单位长度(L2 norm = 1.0
///
/// 特性:
/// - **确定性**:相同输入 → 相同向量
/// - **有区分度**:不同输入产生不同向量(高概率)
/// - **单位范数**:余弦相似度等价于点积
/// - **开销极低**:不分配额外内存,无 IO
///
/// # 已知限制
///
/// `f32::sin(seed + i) * 10000` 在维度较高时(如 1536OpenAI Embedding 维度)
/// 可能出现周期性模式——相邻维度取值在 `sin` 周期 2π 约束下呈规律性重复。
/// MockEmbedding 仅用于测试验证,**不应用于生产级相似度排序**;
/// 做严肃验证时建议使用真实 Embedding Provider 或显式随机初始化。
pub struct MockEmbedding {
dim: usize,
}
impl MockEmbedding {
/// 创建 Mock Embedding,输出向量维度为 `dim`。
pub fn new(dim: usize) -> Self {
Self { dim }
}
}
#[async_trait]
impl Embedding for MockEmbedding {
async fn embed(&self, input: &[String]) -> Result<Vec<Vec<f32>>, LlmError> {
let results: Vec<Vec<f32>> = input
.iter()
.map(|text| {
// 简单哈希:字符字节值和 + 文本长度作为种子
let seed: f64 = text.bytes().map(|b| b as f64).sum::<f64>() + text.len() as f64;
let mut vec: Vec<f32> = (0..self.dim)
.map(|i| f32::sin(seed as f32 + i as f32) * 10000.0)
.collect();
l2_normalize(&mut vec);
vec
})
.collect();
Ok(results)
}
fn dim(&self) -> usize {
self.dim
}
}
/// L2 归一化(in-place)。
///
/// 零向量(norm == 0)保持全零 —— 防除零保护。
fn l2_normalize(vec: &mut [f32]) {
let norm: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > f32::EPSILON {
for x in vec.iter_mut() {
*x /= norm;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 计算向量的 L2 范数。
fn l2_norm(v: &[f32]) -> f32 {
v.iter().map(|x| x * x).sum::<f32>().sqrt()
}
#[tokio::test]
async fn embed_correct_dim() {
let embedder = MockEmbedding::new(8);
let inputs = vec!["hello".to_string(), "world".to_string()];
let result = embedder.embed(&inputs).await.unwrap();
assert_eq!(result.len(), 2);
for vec in &result {
assert_eq!(vec.len(), 8);
}
}
#[tokio::test]
async fn embed_batch_size_match() {
let embedder = MockEmbedding::new(4);
let inputs = vec![
"a".to_string(),
"b".to_string(),
"c".to_string(),
"d".to_string(),
"e".to_string(),
];
let result = embedder.embed(&inputs).await.unwrap();
assert_eq!(result.len(), inputs.len());
}
#[tokio::test]
async fn embed_deterministic() {
let embedder = MockEmbedding::new(4);
let inputs = vec!["deterministic test".to_string()];
let r1 = embedder.embed(&inputs).await.unwrap();
let r2 = embedder.embed(&inputs).await.unwrap();
assert_eq!(r1, r2);
}
#[tokio::test]
async fn embed_unit_vector_norm() {
let embedder = MockEmbedding::new(16);
let inputs = vec!["any text".to_string(), "another".to_string()];
let result = embedder.embed(&inputs).await.unwrap();
for vec in &result {
let norm = l2_norm(vec);
assert!((norm - 1.0).abs() < 1e-5, "vector norm should be ~1.0, got {}", norm);
}
}
#[tokio::test]
async fn embed_different_inputs_different_vectors() {
let embedder = MockEmbedding::new(16);
let r1 = embedder
.embed(&["hello world".to_string()])
.await
.unwrap();
let r2 = embedder
.embed(&["completely different".to_string()])
.await
.unwrap();
assert_ne!(r1, r2);
}
#[tokio::test]
async fn embed_empty_string() {
// 空字符串输入应不 panic,且向量范数仍≈1.0(防除零路径)
let embedder = MockEmbedding::new(4);
let inputs = vec!["".to_string()];
let result = embedder.embed(&inputs).await.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].len(), 4);
let norm = l2_norm(&result[0]);
assert!((norm - 1.0).abs() < 1e-5, "empty-string vector norm should be ~1.0, got {}", norm);
}
}
+315 -8
View File
@@ -25,15 +25,319 @@ use super::{LlmProvider, ProviderCapabilities, ProviderFeatures};
use crate::llm::convert::{from_openai, to_openai}; use crate::llm::convert::{from_openai, to_openai};
use crate::llm::error::LlmError; use crate::llm::error::LlmError;
use crate::llm::types::message::{ContentBlock, ContentBlockType, Message}; use crate::llm::types::message::{ContentBlock, ContentBlockType, Message};
use crate::llm::types::openai_message::{ContentField, OpenaiChatMessage}; use crate::llm::types::openai_message::{ContentField, OpenaiChatMessage, OpenaiContentPart};
use crate::llm::types::request::{OpenaiChatRequest, OpenaiTool, StreamOptions};
use crate::llm::types::request_v2::MessageRequest; use crate::llm::types::request_v2::MessageRequest;
use crate::llm::types::response::{OpenaiChatChunk, OpenaiChatResponse};
use crate::llm::types::response_v2::{ use crate::llm::types::response_v2::{
MessageResponse, PartialMessageResponse, PartialUsage, StopReason, StreamEvent, MessageResponse, PartialMessageResponse, PartialUsage, StopReason, StreamEvent,
}; };
use crate::llm::types::shared::FinishReason; use crate::llm::types::shared::{FinishReason, ResponseFormat, ServiceTier, StopSequence};
use crate::llm::types::tool::OpenaiToolCall; use crate::llm::types::tool::{OpenaiToolCall, OpenaiToolDefinition, ToolChoice};
use serde::Deserialize;
// =============================================================================
// 0. OpenAI wire-format 类型(Phase 13 从 types::request 迁入)
// =============================================================================
/// 流式响应选项。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct StreamOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub include_usage: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_obfuscation: Option<bool>,
}
/// OpenAI wire-format 工具定义。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type")]
pub(crate) enum OpenaiTool {
Function { function: OpenaiToolDefinition },
}
/// 音频输出参数。
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct AudioParam {
pub format: String,
pub voice: String,
}
/// 预测内容(OpenAI `prediction` 字段)。
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PredictionContent {
#[serde(rename = "type")]
pub pred_type: String,
pub content: String,
}
/// 用户位置(web search 用)。
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct UserLocation {
#[serde(rename = "type")]
pub loc_type: String,
pub approximate: Approximate,
}
/// 近似位置。
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Approximate {
pub city: String,
pub country: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
}
/// Web search 选项。
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct WebSearchOptions {
pub search_context_size: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_location: Option<UserLocation>,
}
/// OpenAI Chat Completions 请求体。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct OpenaiChatRequest {
pub model: String,
pub messages: Vec<OpenaiChatMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frequency_penalty: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logit_bias: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub n: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub presence_penalty: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_format: Option<ResponseFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<ServiceTier>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<StopSequence>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<StreamOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<OpenaiTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parallel_tool_calls: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_headers: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_body: Option<Value>,
}
// =============================================================================
// 0b. OpenAI wire-format 响应类型(Phase 13 从 types::response 迁入)
// =============================================================================
/// 单 token logprob。
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct TokenLogprob {
pub token: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes: Option<Vec<u32>>,
pub logprob: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_logprobs: Option<Vec<TopLogprob>>,
}
/// Top-K logprob。
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct TopLogprob {
pub token: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes: Option<Vec<u32>>,
pub logprob: f64,
}
/// Logprobs 容器。
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Logprobs {
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<Vec<TokenLogprob>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refusal: Option<Vec<TokenLogprob>>,
}
/// URL 引用(annotation 用)。
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct URLCitation {
pub end_index: u32,
pub start_index: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub url: String,
}
/// 注释(response 中可包含)。
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Annotation {
#[serde(rename = "type")]
pub ann_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub url_citation: Option<URLCitation>,
}
/// OpenAI 音频输出。
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct OpenaiAudio {
pub id: String,
pub data: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub transcript: Option<String>,
}
/// 非流式 choice。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Choice {
pub index: u32,
pub message: OpenaiChatMessage,
#[serde(skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<FinishReason>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logprobs: Option<Logprobs>,
}
/// OpenAI Chat Completions 响应。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct OpenaiChatResponse {
pub id: String,
pub object: String,
pub created: u64,
pub model: String,
pub choices: Vec<Choice>,
pub usage: crate::llm::types::usage::Usage,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_fingerprint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<ServiceTier>,
}
/// 流式响应 delta。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Delta {
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refusal: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<OpenaiToolCall>>,
}
/// 流式 chunk 的 choice。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ChunkChoice {
pub index: u32,
pub delta: Delta,
#[serde(skip_serializing_if = "Option::is_none")]
pub logprobs: Option<Logprobs>,
#[serde(skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<FinishReason>,
}
/// OpenAI Chat Completions 流式 chunk。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct OpenaiChatChunk {
pub id: String,
pub object: String,
pub created: u64,
pub model: String,
pub choices: Vec<ChunkChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<crate::llm::types::usage::Usage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_fingerprint: Option<String>,
}
impl From<OpenaiChatMessage> for Delta {
fn from(msg: OpenaiChatMessage) -> Self {
match msg {
OpenaiChatMessage::Assistant {
content,
tool_calls,
..
} => Delta {
role: Some("assistant".to_string()),
content: match content {
ContentField::String(s) => Some(s),
ContentField::Array(parts) => {
let mut text = String::new();
for part in parts {
if let OpenaiContentPart::Text { text: t } = part {
text.push_str(&t);
}
}
if text.is_empty() { None } else { Some(text) }
}
},
refusal: None,
tool_calls,
},
_ => Delta {
role: None,
content: None,
refusal: None,
tool_calls: None,
},
}
}
}
impl From<OpenaiChatResponse> for OpenaiChatChunk {
fn from(response: OpenaiChatResponse) -> Self {
let choices = response
.choices
.into_iter()
.map(|c| ChunkChoice {
index: c.index,
delta: Delta::from(c.message),
logprobs: c.logprobs,
finish_reason: c.finish_reason,
})
.collect();
OpenaiChatChunk {
id: response.id,
object: "chat.completion.chunk".to_string(),
created: response.created,
model: response.model,
choices,
usage: Some(response.usage),
system_fingerprint: response.system_fingerprint,
}
}
}
// ============================================================================= // =============================================================================
// 1. GenericOpenaiProvider —— OpenAI-compatible 协议共用实现 // 1. GenericOpenaiProvider —— OpenAI-compatible 协议共用实现
@@ -210,7 +514,10 @@ impl GenericOpenaiProvider {
/// ///
/// 实现注意:先在函数顶部抽取出所有 needed 字段(clone 或 move),避免后续 /// 实现注意:先在函数顶部抽取出所有 needed 字段(clone 或 move),避免后续
/// 部分移动 `request` 后无法借用其它字段。 /// 部分移动 `request` 后无法借用其它字段。
pub fn convert_request(&self, request: MessageRequest) -> Result<OpenaiChatRequest, LlmError> { pub(crate) fn convert_request(
&self,
request: MessageRequest,
) -> Result<OpenaiChatRequest, LlmError> {
// ponytail: 先抽取 / clone 所有 owned 字段,再访问 request.extra // ponytail: 先抽取 / clone 所有 owned 字段,再访问 request.extra
// 避免部分移动导致后续 `&self` borrow 失败。 // 避免部分移动导致后续 `&self` borrow 失败。
let model = request.model.clone(); let model = request.model.clone();
@@ -273,7 +580,7 @@ impl GenericOpenaiProvider {
/// `OpenaiChatResponse` → `MessageResponse`。 /// `OpenaiChatResponse` → `MessageResponse`。
/// ///
/// 返回 `Err(LlmError::Other)` 当 `choices` 为空。 /// 返回 `Err(LlmError::Other)` 当 `choices` 为空。
pub fn convert_response( pub(crate) fn convert_response(
&self, &self,
response: OpenaiChatResponse, response: OpenaiChatResponse,
) -> Result<MessageResponse, LlmError> { ) -> Result<MessageResponse, LlmError> {
@@ -986,7 +1293,7 @@ data: [DONE]\n\n";
object: "chat.completion".into(), object: "chat.completion".into(),
created: 0, created: 0,
model: "gpt-4o".into(), model: "gpt-4o".into(),
choices: vec![crate::llm::types::response::Choice { choices: vec![Choice {
index: 0, index: 0,
message: OpenaiChatMessage::Assistant { message: OpenaiChatMessage::Assistant {
content: ContentField::String(String::new()), content: ContentField::String(String::new()),
+5 -199
View File
@@ -1,203 +1,9 @@
//! 流式事件系统 —— 将 LLM 流式响应解析为语义化事件 //! 流式事件系统 —— 重导出 `StreamEvent` 供向后兼容
//! //!
//! Phase 0 修订(参见 `docs/10a-phase0-types-and-trait.md` §"StreamEvent 命名冲突处理"): //! 历史说明(Phase 0 → Phase 13):
//! - 对外暴露的 `StreamEvent` 是高精度 IR 版本(来自 `response_v2::StreamEvent`)。 //! - 对外暴露的 `StreamEvent` 是高精度 IR 版本(来自 `response_v2::StreamEvent`)。
//! - 旧变体(`AssistantTextDelta` / `ToolExecutionStarted` 等)重命名为 `LegacyStreamEvent` //! - 旧版 chunk 解析 + LegacyStreamEvent 适配层在 Phase 13 完成后已整体删除。
//! 放在 `crate::llm::types::old_stream` 模块,本文件内部消费。 //! - 当前文件仅保留 `pub use` 重导出,保持与既有
//! - Phase 1 重写 Provider 时可直接消费新事件流后整体删除 `LegacyStreamEvent` 相关代码 //! `use crate::llm::stream::StreamEvent` 的代码兼容
//!
//! 当前实现:旧的 `parse_chunk_stream` 内部消费 `OpenaiChatChunk`,映射为
//! `LegacyStreamEvent`,再在 `LegacyToIrEventStream` 中映射为新 IR `StreamEvent`
//! 后输出。Phase 1 会重写此层(OpenAI Provider 直接产出新事件流)。
use std::pin::Pin;
use std::task::{Context, Poll};
use futures_core::stream::Stream;
use futures_util::FutureExt;
use futures_util::future::poll_fn;
use serde_json::Value;
use crate::llm::error::LlmError;
use crate::llm::types::old_stream::LegacyStreamEvent;
use crate::llm::types::response_v2::MessageResponse;
use crate::llm::types::response_v2::StopReason;
use crate::llm::types::usage::Usage;
use crate::llm::types::{OpenaiChatChunk, OpenaiToolCall};
// 唯一的对外 `StreamEvent` 定义(高精度 IR 事件,来自 `response_v2`)。
//
// 此 `pub use` 同时起到两个作用:
// 1. 让 `crate::llm::stream::StreamEvent` 路径仍指向新高精度 IR 事件,
// 保持与既有 `use crate::llm::stream::StreamEvent` 的代码兼容;
// 2. 把模块内部的 `StreamEvent` 名字指向 `response_v2::StreamEvent`。
pub use crate::llm::types::response_v2::StreamEvent; pub use crate::llm::types::response_v2::StreamEvent;
/// 将原始 OpenaiChatChunk 流解析为新高精度 IR StreamEvent 流。
///
/// ponytail: 每个产出事件都用 `Result<_, LlmError>` 包装,让上层 `chat_stream`
/// trait 方法直接消费并保持错误传播链。当前 `LegacyToIrEventStream` 内部
/// 不会产生错误,所有结果都是 `Ok`;后续 Phase 1 重写 Provider 时,
/// 真实 IR 流转换可在此层注入 error 事件。
pub fn parse_chunk_stream(
chunks: Pin<Box<dyn futures_core::Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>,
) -> Pin<Box<dyn futures_core::Stream<Item = Result<StreamEvent, LlmError>> + Send>> {
let legacy = parse_chunk_stream_legacy(chunks);
Box::pin(LegacyToIrEventStream { inner: legacy })
}
// --- 内部:chunk → LegacyStreamEvent ---
fn parse_chunk_stream_legacy(
chunks: Pin<Box<dyn futures_core::Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>,
) -> Pin<Box<dyn futures_core::Stream<Item = LegacyStreamEvent> + Send>> {
Box::pin(ChunkToLegacyEventStream { chunks })
}
struct ChunkToLegacyEventStream {
chunks: Pin<Box<dyn futures_core::Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>,
}
impl Stream for ChunkToLegacyEventStream {
type Item = LegacyStreamEvent;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = &mut *self;
poll_fn(|cx| match Pin::new(&mut this.chunks).poll_next(cx) {
Poll::Ready(Some(Ok(chunk))) => {
for choice in &chunk.choices {
let delta = &choice.delta;
if let Some(content) = &delta.content {
return Poll::Ready(Some(LegacyStreamEvent::AssistantTextDelta {
text: content.clone(),
}));
}
if let Some(tool_calls) = &delta.tool_calls
&& let Some(tc) = tool_calls.first()
{
let OpenaiToolCall::Function { id, function } = tc;
let args: Value =
serde_json::from_str(&function.arguments).unwrap_or(Value::Null);
return Poll::Ready(Some(LegacyStreamEvent::ToolExecutionStarted {
tool_name: function.name.clone(),
input: args,
tool_call_id: id.clone(),
}));
}
if let Some(finish_reason) = &choice.finish_reason {
return Poll::Ready(Some(LegacyStreamEvent::TurnComplete {
reason: *finish_reason,
}));
}
}
if let Some(usage) = &chunk.usage {
return Poll::Ready(Some(LegacyStreamEvent::CostUpdate { usage: *usage }));
}
Poll::Ready(None)
}
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(LegacyStreamEvent::error(e.to_string()))),
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
})
.poll_unpin(cx)
}
}
// --- 内部:LegacyStreamEvent → 新 StreamEvent ---
struct LegacyToIrEventStream {
inner: Pin<Box<dyn futures_core::Stream<Item = LegacyStreamEvent> + Send>>,
}
impl Stream for LegacyToIrEventStream {
type Item = Result<StreamEvent, LlmError>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = &mut *self;
match Pin::new(&mut this.inner).poll_next(cx) {
Poll::Ready(Some(legacy)) => Poll::Ready(Some(Ok(map_legacy_to_ir(legacy)))),
Poll::Ready(None) => {
// 旧流结束 → 主动补一个 MessageCompletefull_response 为兜底空快照)。
// ponytail: Phase 0 中 OpenaiProvider 桥接层负责产出真实 MessageResponse
// 此处仅防止消费方无限等待。若 Provider 层已正确发出 MessageComplete
// LlmCycle 不会走到这里 —— 因为桥接层 inline 处理。
Poll::Ready(Some(Ok(StreamEvent::MessageComplete {
full_response: empty_message_response(),
})))
}
Poll::Pending => Poll::Pending,
}
}
}
fn empty_message_response() -> MessageResponse {
use crate::llm::types::message::Message;
use std::collections::HashMap;
MessageResponse {
id: String::new(),
model: String::new(),
message: Message::Assistant { content: vec![] },
usage: Usage::default(),
stop_reason: StopReason::Stop,
extra: HashMap::new(),
}
}
/// 把旧 LegacyStreamEvent 映射到新高精度 IR StreamEvent。
///
/// Phase 1 重写 Provider 后可直接删除此映射函数。当前映射语义:
/// - `AssistantTextDelta` → `TextDelta`
/// - `ToolExecutionStarted` → `ToolCallArgumentsDelta`OpenAI 单 chunk 模式下整段 arguments 一次性下发)
/// - `CostUpdate` → `CostUpdate`Usage → PartialUsage 全字段)
/// - `TurnComplete` → `MessageComplete`Phase 1 重写 Provider 后正确产出)
/// - `Error` → `Error`
///
/// ponytail: 这是一个"目前能跑通未来会被删除"的适配层。当前实现为单事件映射,
/// 旧 `ToolExecutionStarted` 携带的 (id, name) 暂未填入 IR 事件(消费方
/// Phase 2 中通过 MessageComplete.full_response.tool_use 提取)。Phase 1 重写时
/// 由 OpenAI Provider 直接产出 IR 流,整体删除此映射。
fn map_legacy_to_ir(legacy: LegacyStreamEvent) -> StreamEvent {
use crate::llm::types::response_v2::PartialUsage;
match legacy {
LegacyStreamEvent::AssistantTextDelta { text } => StreamEvent::TextDelta { text },
LegacyStreamEvent::ToolExecutionStarted { input, .. } => {
let arguments = serde_json::to_string(&input).unwrap_or_default();
StreamEvent::ToolCallArgumentsDelta {
index: 0,
arguments,
}
}
LegacyStreamEvent::ToolExecutionCompleted { .. } => {
// 旧 ToolExecutionCompleted 不在 IR 流协议中——工具执行是消费方职责。
// Phase 1 重写时此处整体删除。当前给一个无副作用的占位事件。
StreamEvent::CostUpdate {
usage: PartialUsage::default(),
}
}
LegacyStreamEvent::CostUpdate { usage } => StreamEvent::CostUpdate {
usage: PartialUsage {
prompt_tokens: Some(usage.prompt_tokens),
completion_tokens: Some(usage.completion_tokens),
total_tokens: Some(usage.total_tokens),
completion_tokens_details: usage.completion_tokens_details,
prompt_tokens_details: usage.prompt_tokens_details,
},
},
LegacyStreamEvent::TurnComplete { reason } => {
// 旧 TurnComplete 不直接对应 IR;映射为带 StopReason 的 MessageComplete。
// ponytail: Phase 1 重写 Provider 后此适配整体删除,
// OpenAI Provider 直接产出带正确 stop_reason 的 MessageComplete。
let _ = reason;
StreamEvent::MessageComplete {
full_response: empty_message_response(),
}
}
LegacyStreamEvent::Error { message } => StreamEvent::Error { message },
}
}
+1 -77
View File
@@ -1,9 +1,6 @@
pub mod message; pub mod message;
pub mod old_stream;
pub mod openai_message; pub mod openai_message;
pub mod request;
pub mod request_v2; pub mod request_v2;
pub mod response;
pub mod response_v2; pub mod response_v2;
pub mod shared; pub mod shared;
pub mod tool; pub mod tool;
@@ -12,12 +9,7 @@ pub mod usage;
pub use openai_message::{ pub use openai_message::{
ContentField, FileData, ImageURL, InputAudio, OpenaiChatMessage, OpenaiContentPart, ContentField, FileData, ImageURL, InputAudio, OpenaiChatMessage, OpenaiContentPart,
}; };
pub use request::{OpenaiChatRequest, OpenaiTool, StreamOptions, ToolChoice};
pub use request_v2::{ExtraError, MessageRequest, ThinkingConfig}; pub use request_v2::{ExtraError, MessageRequest, ThinkingConfig};
pub use response::{
Annotation, Choice, ChunkChoice, Delta, Logprobs, OpenaiAudio, OpenaiChatChunk,
OpenaiChatResponse, TokenLogprob, TopLogprob, URLCitation,
};
pub use response_v2::{ pub use response_v2::{
ContentBlockBuilder, MessageResponse, PartialMessageResponse, PartialUsage, StopReason, ContentBlockBuilder, MessageResponse, PartialMessageResponse, PartialUsage, StopReason,
StreamEvent, StreamEvent,
@@ -26,73 +18,5 @@ pub use shared::{
AudioFormat, FinishReason, ImageDetail, Modality, ResponseFormat, Role, ServiceTier, AudioFormat, FinishReason, ImageDetail, Modality, ResponseFormat, Role, ServiceTier,
StopSequence, StopSequence,
}; };
pub use tool::{FunctionCall, OpenaiToolCall, ToolDef}; pub use tool::{FunctionCall, OpenaiToolCall, ToolChoice, ToolDef};
pub use usage::{CompletionTokensDetails, CostTracker, PromptTokensDetails, Usage}; pub use usage::{CompletionTokensDetails, CostTracker, PromptTokensDetails, Usage};
// Re-export IR 内容块 / 消息类型供 `types::ContentBlock` 等历史路径消费。
//
// 注意:以下别名 *故意不暴露* `pub type Message = message::Message`、
// `pub type ContentBlock = message::ContentBlock` —— 新 `Message` / `ContentBlock` /
// `StopReason` 是独立类型,由 `Message` / `ContentBlock` / `StopReason` 直接路径访问,
// 旧别名(指 `OpenaiChatMessage` / `OpenaiContentPart` / `FinishReason`)已移除,
// 避免新类型阴影。Phase 2 完成后再统一收敛。
//
// Phase 1 起移除 `ChatRequest` 别名 —— 新代码统一使用 `MessageRequest`v2 IR)。
// `ChatResponse` 结构体仍存在,作为 OpenAI `chat_inner()` 内部 wire-format 转换目标。
/// 旧 wire-format 响应结构(保留用于 OpenAI 内部转换层)。
#[deprecated(since = "0.1.0", note = "请改用 MessageResponse")]
#[derive(Debug, Clone)]
pub struct ChatResponse {
pub message: OpenaiChatMessage,
pub usage: Usage,
pub stop_reason: Option<FinishReason>,
}
#[allow(deprecated)]
impl From<OpenaiChatResponse> for ChatResponse {
fn from(response: OpenaiChatResponse) -> Self {
let message = response
.choices
.first()
.map(|c| c.message.clone())
.unwrap_or_else(|| OpenaiChatMessage::assistant_text(""));
let stop_reason = response.choices.first().and_then(|c| c.finish_reason);
ChatResponse {
message,
usage: response.usage,
stop_reason,
}
}
}
#[allow(deprecated)]
impl From<ChatResponse> for OpenaiChatChunk {
fn from(response: ChatResponse) -> Self {
let delta = Delta::from(response.message.clone());
let chunk_choice = ChunkChoice {
index: 0,
delta,
logprobs: None,
finish_reason: response.stop_reason,
};
OpenaiChatChunk {
id: format!(
"chunk-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
),
object: "chat.completion.chunk".to_string(),
created: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
model: String::new(),
choices: vec![chunk_choice],
usage: Some(response.usage),
system_fingerprint: None,
}
}
}
-45
View File
@@ -1,45 +0,0 @@
//! 旧版流式事件 —— Phase 0 临时保留,仅供 `stream.rs` 中 `parse_chunk_stream` 内部使用。
//!
//! Phase 0 中:高精度 `StreamEvent`(定义在 `response_v2.rs`)是唯一的对外
//! `StreamEvent`,旧变体迁移至此模块改名为 `LegacyStreamEvent`
//! 由 `parse_chunk_stream()` 内部消费 `LegacyStreamEvent`,对外返回值已被
//! 重映射为新 `StreamEvent`。
//!
//! Phase 1 重写 Provider 时,`parse_chunk_stream` 可直接消费新事件流后整体删除此文件。
use crate::llm::types::shared::FinishReason;
use crate::llm::types::usage::Usage;
use serde_json::Value;
/// 旧 `StreamEvent` 变体迁移后的别名 —— 仅供 `stream.rs` 内部使用。
#[derive(Debug, Clone)]
pub enum LegacyStreamEvent {
/// 助手回复文本增量。
AssistantTextDelta { text: String },
/// 工具调用开始。
ToolExecutionStarted {
tool_name: String,
input: Value,
tool_call_id: String,
},
/// 工具调用完成。
ToolExecutionCompleted {
tool_name: String,
output: Value,
is_error: bool,
},
/// Token 用量更新。
CostUpdate { usage: Usage },
/// 一轮会话完成。
TurnComplete { reason: FinishReason },
/// 错误事件。
Error { message: String },
}
impl LegacyStreamEvent {
pub(crate) fn error(message: impl Into<String>) -> Self {
Self::Error {
message: message.into(),
}
}
}
-187
View File
@@ -1,187 +0,0 @@
use crate::llm::types::shared::{ResponseFormat, ServiceTier, StopSequence};
use crate::llm::types::tool::OpenaiToolDefinition;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub include_usage: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_obfuscation: Option<bool>,
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub enum ToolChoice {
#[default]
None,
Auto,
Required,
Named {
name: String,
},
AllowedTools {
tool_names: Vec<String>,
},
}
impl Serialize for ToolChoice {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
ToolChoice::None => serializer.serialize_str("none"),
ToolChoice::Auto => serializer.serialize_str("auto"),
ToolChoice::Required => serializer.serialize_str("required"),
ToolChoice::Named { name } => {
let obj = serde_json::json!({
"type": "function",
"function": { "name": name }
});
obj.serialize(serializer)
}
ToolChoice::AllowedTools { tool_names } => {
let obj = serde_json::json!({
"type": "function",
"function": { "name": tool_names.first().cloned().unwrap_or_default() }
});
obj.serialize(serializer)
}
}
}
}
impl<'de> Deserialize<'de> for ToolChoice {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
match value {
Value::String(s) => match s.as_str() {
"none" => Ok(ToolChoice::None),
"auto" => Ok(ToolChoice::Auto),
"required" => Ok(ToolChoice::Required),
_ => Err(serde::de::Error::custom(format!(
"unknown tool choice: {s}"
))),
},
Value::Object(obj) => {
let typ = obj.get("type").and_then(|v| v.as_str()).ok_or_else(|| {
serde::de::Error::custom("missing 'type' field in tool_choice")
})?;
if typ == "function" {
let func =
obj.get("function")
.and_then(|v| v.as_object())
.ok_or_else(|| {
serde::de::Error::custom("missing 'function' field in tool_choice")
})?;
let name = func.get("name").and_then(|v| v.as_str()).ok_or_else(|| {
serde::de::Error::custom("missing 'function.name' in tool_choice")
})?;
Ok(ToolChoice::Named {
name: name.to_string(),
})
} else {
Err(serde::de::Error::custom(format!(
"unknown tool_choice type: {typ}"
)))
}
}
_ => Err(serde::de::Error::custom(
"tool_choice must be a string or object",
)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum OpenaiTool {
Function { function: OpenaiToolDefinition },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioParam {
pub format: String,
pub voice: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PredictionContent {
#[serde(rename = "type")]
pub pred_type: String,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserLocation {
#[serde(rename = "type")]
pub loc_type: String,
pub approximate: Approximate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Approximate {
pub city: String,
pub country: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebSearchOptions {
pub search_context_size: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_location: Option<UserLocation>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct OpenaiChatRequest {
pub model: String,
pub messages: Vec<crate::llm::types::openai_message::OpenaiChatMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frequency_penalty: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logit_bias: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub n: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub presence_penalty: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_format: Option<ResponseFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<ServiceTier>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<StopSequence>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<StreamOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<OpenaiTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parallel_tool_calls: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_headers: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_body: Option<Value>,
}
+1 -1
View File
@@ -9,7 +9,7 @@ use serde_json::Value;
use thiserror::Error; use thiserror::Error;
use crate::llm::types::message::Message; use crate::llm::types::message::Message;
use crate::llm::types::request::ToolChoice; use crate::llm::types::tool::ToolChoice;
use crate::llm::types::tool::ToolDef; use crate::llm::types::tool::ToolDef;
/// Provider 无关的请求类型。 /// Provider 无关的请求类型。
-177
View File
@@ -1,177 +0,0 @@
use crate::llm::types::openai_message::OpenaiChatMessage;
use crate::llm::types::shared::{FinishReason, ServiceTier};
use crate::llm::types::tool::OpenaiToolCall;
use crate::llm::types::usage::Usage;
use crate::llm::types::{ContentField, OpenaiContentPart};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenLogprob {
pub token: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes: Option<Vec<u32>>,
pub logprob: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_logprobs: Option<Vec<TopLogprob>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopLogprob {
pub token: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes: Option<Vec<u32>>,
pub logprob: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Logprobs {
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<Vec<TokenLogprob>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refusal: Option<Vec<TokenLogprob>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct URLCitation {
pub end_index: u32,
pub start_index: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Annotation {
#[serde(rename = "type")]
pub ann_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub url_citation: Option<URLCitation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenaiAudio {
pub id: String,
pub data: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub transcript: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Choice {
pub index: u32,
pub message: OpenaiChatMessage,
#[serde(skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<FinishReason>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logprobs: Option<Logprobs>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenaiChatResponse {
pub id: String,
pub object: String,
pub created: u64,
pub model: String,
pub choices: Vec<Choice>,
pub usage: Usage,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_fingerprint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<ServiceTier>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Delta {
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refusal: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<OpenaiToolCall>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChunkChoice {
pub index: u32,
pub delta: Delta,
#[serde(skip_serializing_if = "Option::is_none")]
pub logprobs: Option<Logprobs>,
#[serde(skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<FinishReason>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenaiChatChunk {
pub id: String,
pub object: String,
pub created: u64,
pub model: String,
pub choices: Vec<ChunkChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_fingerprint: Option<String>,
}
impl From<OpenaiChatMessage> for Delta {
fn from(msg: OpenaiChatMessage) -> Self {
match msg {
OpenaiChatMessage::Assistant {
content,
tool_calls,
..
} => Delta {
role: Some("assistant".to_string()),
content: match content {
ContentField::String(s) => Some(s),
ContentField::Array(parts) => {
let mut text = String::new();
for part in parts {
if let OpenaiContentPart::Text { text: t } = part {
text.push_str(&t);
}
}
if text.is_empty() { None } else { Some(text) }
}
},
refusal: None,
tool_calls,
},
_ => Delta {
role: None,
content: None,
refusal: None,
tool_calls: None,
},
}
}
}
impl From<OpenaiChatResponse> for OpenaiChatChunk {
fn from(response: OpenaiChatResponse) -> Self {
let choices = response
.choices
.into_iter()
.map(|c| ChunkChoice {
index: c.index,
delta: Delta::from(c.message),
logprobs: c.logprobs,
finish_reason: c.finish_reason,
})
.collect();
OpenaiChatChunk {
id: response.id,
object: "chat.completion.chunk".to_string(),
created: response.created,
model: response.model,
choices,
usage: Some(response.usage),
system_fingerprint: response.system_fingerprint,
}
}
}
+90
View File
@@ -63,3 +63,93 @@ pub struct FunctionCall {
pub enum OpenaiToolCall { pub enum OpenaiToolCall {
Function { id: String, function: FunctionCall }, Function { id: String, function: FunctionCall },
} }
/// 工具选择策略 —— Phase 13 从 `types::request::ToolChoice` 迁入。
///
/// `#[non_exhaustive]` 预留扩展空间。
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub enum ToolChoice {
#[default]
None,
Auto,
Required,
Named {
name: String,
},
AllowedTools {
tool_names: Vec<String>,
},
}
impl Serialize for ToolChoice {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
ToolChoice::None => serializer.serialize_str("none"),
ToolChoice::Auto => serializer.serialize_str("auto"),
ToolChoice::Required => serializer.serialize_str("required"),
ToolChoice::Named { name } => {
let obj = serde_json::json!({
"type": "function",
"function": { "name": name }
});
obj.serialize(serializer)
}
ToolChoice::AllowedTools { tool_names } => {
let obj = serde_json::json!({
"type": "function",
"function": { "name": tool_names.first().cloned().unwrap_or_default() }
});
obj.serialize(serializer)
}
}
}
}
impl<'de> Deserialize<'de> for ToolChoice {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
match value {
Value::String(s) => match s.as_str() {
"none" => Ok(ToolChoice::None),
"auto" => Ok(ToolChoice::Auto),
"required" => Ok(ToolChoice::Required),
_ => Err(serde::de::Error::custom(format!(
"unknown tool choice: {s}"
))),
},
Value::Object(obj) => {
let typ = obj.get("type").and_then(|v| v.as_str()).ok_or_else(|| {
serde::de::Error::custom("missing 'type' field in tool_choice")
})?;
if typ == "function" {
let func =
obj.get("function")
.and_then(|v| v.as_object())
.ok_or_else(|| {
serde::de::Error::custom("missing 'function' field in tool_choice")
})?;
let name = func.get("name").and_then(|v| v.as_str()).ok_or_else(|| {
serde::de::Error::custom("missing 'function.name' in tool_choice")
})?;
Ok(ToolChoice::Named {
name: name.to_string(),
})
} else {
Err(serde::de::Error::custom(format!(
"unknown tool_choice type: {typ}"
)))
}
}
_ => Err(serde::de::Error::custom(
"tool_choice must be a string or object",
)),
}
}
}
+3
View File
@@ -7,6 +7,7 @@ pub mod retriever;
pub mod store; pub mod store;
pub mod types; pub mod types;
pub mod vector; pub mod vector;
pub mod vector_store;
// 高频类型(大多数下游需要) // 高频类型(大多数下游需要)
pub use conversation::{ConversationMemory, ConversationMemoryConfig}; pub use conversation::{ConversationMemory, ConversationMemoryConfig};
@@ -14,7 +15,9 @@ pub use error::MemoryError;
pub use knowledge::KnowledgeStore; pub use knowledge::KnowledgeStore;
pub use retriever::MemoryRetriever; pub use retriever::MemoryRetriever;
pub use store::{InMemoryStore, MemoryStore, SqliteStore}; pub use store::{InMemoryStore, MemoryStore, SqliteStore};
#[allow(deprecated)]
pub use vector::{InMemoryVectorRetriever, VectorRetriever}; pub use vector::{InMemoryVectorRetriever, VectorRetriever};
pub use vector_store::{InMemoryVectorStore, PersistentVectorStore, RagPipeline, VectorStore};
// 低频类型(配置/高级使用) // 低频类型(配置/高级使用)
pub use conversation::MemoryStrategy; pub use conversation::MemoryStrategy;
+6
View File
@@ -17,6 +17,7 @@ use crate::memory::error::MemoryError;
/// ///
/// **稳定性**:实验性 APIv0.2.x),方法签名可能在 v0.3 中调整。 /// **稳定性**:实验性 APIv0.2.x),方法签名可能在 v0.3 中调整。
/// 若未来需要 `remove()` / `clear()` 等方法,将在此 trait 中追加(带默认实现)。 /// 若未来需要 `remove()` / `clear()` 等方法,将在此 trait 中追加(带默认实现)。
#[deprecated(since = "0.3.0", note = "请使用 memory::VectorStore")]
#[async_trait] #[async_trait]
pub trait VectorRetriever: Send + Sync { pub trait VectorRetriever: Send + Sync {
/// 将 `id` 对应的文本向量 `embeddings` 加入索引。 /// 将 `id` 对应的文本向量 `embeddings` 加入索引。
@@ -44,10 +45,12 @@ pub trait VectorRetriever: Send + Sync {
/// - 不做向量维度校验(不同维度向量查询结果无意义但不 panic) /// - 不做向量维度校验(不同维度向量查询结果无意义但不 panic)
/// - `search()` 是 O(n) 全量扫描,未做索引加速 /// - `search()` 是 O(n) 全量扫描,未做索引加速
/// - 不保证高并发下查询时序与写入顺序一致 /// - 不保证高并发下查询时序与写入顺序一致
#[deprecated(since = "0.3.0", note = "请使用 memory::InMemoryVectorStore")]
pub struct InMemoryVectorRetriever { pub struct InMemoryVectorRetriever {
vectors: Mutex<HashMap<String, Vec<f32>>>, vectors: Mutex<HashMap<String, Vec<f32>>>,
} }
#[allow(deprecated)]
impl InMemoryVectorRetriever { impl InMemoryVectorRetriever {
/// 创建空检索器。 /// 创建空检索器。
pub fn new() -> Self { pub fn new() -> Self {
@@ -57,12 +60,14 @@ impl InMemoryVectorRetriever {
} }
} }
#[allow(deprecated)]
impl Default for InMemoryVectorRetriever { impl Default for InMemoryVectorRetriever {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
} }
} }
#[allow(deprecated)]
#[async_trait] #[async_trait]
impl VectorRetriever for InMemoryVectorRetriever { impl VectorRetriever for InMemoryVectorRetriever {
async fn index(&self, id: String, embeddings: Vec<f32>) -> Result<(), MemoryError> { async fn index(&self, id: String, embeddings: Vec<f32>) -> Result<(), MemoryError> {
@@ -118,6 +123,7 @@ fn dot(a: &[f32], b: &[f32]) -> f32 {
} }
#[cfg(test)] #[cfg(test)]
#[allow(deprecated)]
mod tests { mod tests {
use super::*; use super::*;
use std::sync::Arc; use std::sync::Arc;
+937
View File
@@ -0,0 +1,937 @@
//! 向量存储抽象与实现 —— RAG 管线「存储与检索」环节。
//!
//! 提供 [`VectorStore`] trait 定义、进程内引用实现 [`InMemoryVectorStore`]
//! 以及基于 [`MemoryStore`] 的持久化包装 [`PersistentVectorStore`] 和
//! RAG 管线组合器 [`RagPipeline`]。
//!
//! 下游可实现 [`VectorStore`] trait 以对接专用向量数据库(pgvector / Qdrant 等)。
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use tracing::{debug, info};
use crate::document::{Document, RecursiveCharacterSplitter};
use crate::llm::embedding::Embedding;
use crate::memory::error::MemoryError;
use crate::memory::store::MemoryStore;
use crate::memory::types::{MemoryFilter, MemoryItem};
/// 向量存储抽象 —— 语义检索的核心接口。
///
/// 提供文档-向量的批量添加、余弦相似度搜索、批量删除三个核心操作。
/// 所有实现必须满足 `Send + Sync` 以支持跨 `.await` 调用。
///
/// # 并发安全
///
/// 实现内部必须使用线程安全的容器(如 `Mutex<HashMap>` 或 `RwLock`),
/// 允许跨多个 tokio task 共享 `&VectorStore` 引用。
///
/// # 与旧 `VectorRetriever` 的差异
///
/// - `add` 接受批量 `(doc, embedding)` 对;旧 `index` 仅接受单条
/// - `search` 返回 `(Document, f32)`;旧 `search` 返回 `(String, f32)`,调用方需自行维护 id→Document 映射
#[async_trait]
pub trait VectorStore: Send + Sync {
/// 批量添加文档及其向量。
///
/// `documents` 和 `embeddings` 必须等长。不等长时:
/// - 截取 `min(len)` 对处理(部分写入已发生)
/// - 返回 `Err(MemoryError::InvalidInput)` 告知截断
/// - 调用方可以 `let _ = store.add(...)` 忽略错误
async fn add(
&self,
documents: &[Document],
embeddings: &[Vec<f32>],
) -> Result<(), MemoryError>;
/// 检索与 `query` 向量最相似的 `k` 条记录。
///
/// 返回 `Vec<(Document, f32)>`,其中 `f32` 为余弦相似度分数,
/// 取值范围 `[0.0, 1.0]`(对单位向量),按分数降序排列。
///
/// # 守卫
///
/// - 空索引 → 返回 `vec![]`
/// - `k == 0` → 返回 `vec![]`
/// - 零向量(norm ≈ 0)→ 返回 `vec![]`
async fn search(
&self,
query: &[f32],
k: usize,
) -> Result<Vec<(Document, f32)>, MemoryError>;
/// 批量删除文档(幂等)。
///
/// 不存在的 id 静默忽略,不会返回错误。
async fn remove(&self, ids: &[String]) -> Result<(), MemoryError>;
/// 便捷方法:单条添加。
///
/// 等价于 `self.add(&[doc], &[emb]).await`。
async fn add_one(&self, doc: Document, emb: Vec<f32>) -> Result<(), MemoryError> {
self.add(&[doc], &[emb]).await
}
}
/// 内存向量存储 —— `VectorStore` 的引用实现。
///
/// 内部使用 `Mutex<HashMap<String, (Document, Vec<f32>)>>` 存储,
/// `search()` 执行 O(n) 全量余弦相似度扫描,适用于 ≤10K 条向量的场景。
///
/// # 并发安全
///
/// 使用 `std::sync::Mutex`(非 tokio Mutex)。
///
/// **锁持有时间评估**
/// - `add()` / `remove()`:微秒级(HashMap 插入/删除操作)
/// - `search()`:毫秒级(O(n) 全量扫描 + 余弦计算),对 10K 条 1536 维向量预估 1-10ms。
/// 实现时在锁内克隆数据快照到 `Vec` 后立即释放锁,在锁外进行余弦相似度计算,
/// 避免长时间持有锁阻塞并发写操作。
pub struct InMemoryVectorStore {
entries: Mutex<HashMap<String, (Document, Vec<f32>)>>,
}
impl InMemoryVectorStore {
/// 创建一个空存储。
pub fn new() -> Self {
Self {
entries: Mutex::new(HashMap::new()),
}
}
/// 从预填充的 entries 构造(供 `PersistentVectorStore` 使用)。
pub(crate) fn with_entries(
entries: HashMap<String, (Document, Vec<f32>)>,
) -> Self {
Self {
entries: Mutex::new(entries),
}
}
}
impl Default for InMemoryVectorStore {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl VectorStore for InMemoryVectorStore {
async fn add(
&self,
documents: &[Document],
embeddings: &[Vec<f32>],
) -> Result<(), MemoryError> {
let mut entries = self
.entries
.lock()
.map_err(|e| MemoryError::RetrievalError(format!("lock poisoned: {e}")))?;
let n = documents.len().min(embeddings.len());
if documents.len() != embeddings.len() {
tracing::warn!(
docs = documents.len(),
embs = embeddings.len(),
"InMemoryVectorStore::add 长度不匹配,截断到 min"
);
}
for i in 0..n {
entries.insert(documents[i].id.clone(), (documents[i].clone(), embeddings[i].clone()));
}
if documents.len() != embeddings.len() {
return Err(MemoryError::InvalidInput(format!(
"documents.len()={} 与 embeddings.len()={} 不等,已截断到 min={}",
documents.len(),
embeddings.len(),
n
)));
}
Ok(())
}
async fn search(
&self,
query: &[f32],
k: usize,
) -> Result<Vec<(Document, f32)>, MemoryError> {
if k == 0 {
return Ok(Vec::new());
}
tracing::trace!(k, "InMemoryVectorStore::search");
// 零向量守卫:查询向量本身为零向量则返回空
let query_norm_sq: f32 = query.iter().map(|x| x * x).sum();
if query_norm_sq < 1e-20 {
return Ok(Vec::new());
}
// 锁内克隆快照,释放锁后在锁外计算余弦
let snapshot: Vec<(Document, Vec<f32>)> = {
let entries = self
.entries
.lock()
.map_err(|e| MemoryError::RetrievalError(format!("lock poisoned: {e}")))?;
entries.values().cloned().collect()
};
let mut scored: Vec<(Document, f32)> = Vec::with_capacity(snapshot.len());
for (doc, emb) in snapshot {
let score = cosine_similarity(query, &emb);
scored.push((doc, score));
}
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(k);
Ok(scored)
}
async fn remove(&self, ids: &[String]) -> Result<(), MemoryError> {
let mut entries = self
.entries
.lock()
.map_err(|e| MemoryError::RetrievalError(format!("lock poisoned: {e}")))?;
tracing::debug!(count = ids.len(), "InMemoryVectorStore::remove");
entries.retain(|key, _| !ids.iter().any(|id| id == key));
Ok(())
}
}
/// 点积。
///
/// `zip` 对不等长向量静默截断到较短者。调用方应保证 `a` 和 `b` 等长——
/// 不等长时结果无意义但不 panic。
fn dot(a: &[f32], b: &[f32]) -> f32 {
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
}
/// 余弦相似度,加 `1e-10` 防除零。
///
/// 零向量与任意向量的相似度返回 `0.0`(因分母中 `1e-10` 保护 + 分子为 0)。
pub(crate) fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
let dot_product = dot(a, b);
let norm_a = dot(a, a).sqrt();
let norm_b = dot(b, b).sqrt();
dot_product / (norm_a * norm_b + 1e-10)
}
/// 持久化向量存储 —— 基于 [`MemoryStore`] 的持久化包装。
///
/// # 架构
///
/// 运行时全量加载到 [`InMemoryVectorStore`] 做余弦搜索,
/// 写操作(add/remove)同时同步到内存和后端 [`MemoryStore`]。
///
/// # 存储格式
///
/// 每条向量存为一条 [`MemoryItem`]
/// - `id`: `"vec:{namespace}:{doc_id}"`colon-separated namespace 前缀)
/// - `content`: JSON 序列化的向量条目(含 doc_id / content / metadata / mime_type / embedding
/// - `metadata`: 空 `serde_json::Value::Null`
///
/// # 构造开销
///
/// `new()` 通过 `store.list(prefix)` 全量加载已有条目,
/// 时间复杂度 O(N)(N 为已有向量数),适用于 ≤10K 条的场景。
pub struct PersistentVectorStore {
inner: InMemoryVectorStore,
store: Arc<dyn MemoryStore>,
namespace: String,
}
/// 持久化向量条目 —— JSON blob 格式。
#[derive(Serialize, Deserialize)]
struct VectorEntry {
doc_id: String,
content: String,
metadata: HashMap<String, String>,
mime_type: String,
embedding: Vec<f32>,
/// ISO 8601 创建时间(UTC),持久化 roundtrip 重建时保持原时间,
/// 避免 MemoryStore 的 TTL 淘汰策略误判。
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
created_at: Option<String>,
}
impl PersistentVectorStore {
/// 创建新的持久化向量存储,自动从 `store` 全量加载 namespace 下的所有条目。
///
/// `MemoryStore::list()` 由 `SqliteStore` 内部使用 `spawn_blocking` 卸载,
/// 加载过程本身在 async context 中即可,无需额外 spawn_blocking。
pub async fn new(
store: Arc<dyn MemoryStore>,
namespace: &str,
) -> Result<Self, MemoryError> {
let prefix = format!("vec:{namespace}:");
let filter = MemoryFilter {
prefix: Some(prefix.clone()),
..Default::default()
};
debug!(namespace = %namespace, "PersistentVectorStore::new — 开始全量加载");
let items = store.list(&filter).await?;
info!(count = items.len(), "PersistentVectorStore::new — 加载完成");
let mut entries: HashMap<String, (Document, Vec<f32>)> = HashMap::new();
for item in items {
let entry: VectorEntry = serde_json::from_str(&item.content)
.map_err(|e| MemoryError::Serialization(e.to_string()))?;
let doc = Document {
id: entry.doc_id,
content: entry.content,
metadata: entry.metadata,
mime_type: entry.mime_type,
};
entries.insert(doc.id.clone(), (doc, entry.embedding));
}
info!(entries = entries.len(), "PersistentVectorStore — 内存索引重建完成");
Ok(Self {
inner: InMemoryVectorStore::with_entries(entries),
store,
namespace: namespace.to_string(),
})
}
}
#[async_trait]
impl VectorStore for PersistentVectorStore {
async fn add(
&self,
documents: &[Document],
embeddings: &[Vec<f32>],
) -> Result<(), MemoryError> {
debug!(count = documents.len(), "PersistentVectorStore::add");
// 先逐个写持久化(失败时不污染内存)
for (doc, emb) in documents.iter().zip(embeddings.iter()) {
let entry = VectorEntry {
doc_id: doc.id.clone(),
content: doc.content.clone(),
metadata: doc.metadata.clone(),
mime_type: doc.mime_type.clone(),
embedding: emb.clone(),
created_at: Some(
OffsetDateTime::now_utc()
.format(&Rfc3339)
.map_err(|e| MemoryError::Serialization(format!("format time: {e}")))?,
),
};
let json = serde_json::to_string(&entry)
.map_err(|e| MemoryError::Serialization(e.to_string()))?;
let key = format!("vec:{}:{}", self.namespace, doc.id);
let item = MemoryItem {
id: key,
content: json,
metadata: serde_json::Value::Null,
created_at: OffsetDateTime::now_utc(),
};
self.store.save(item).await?;
}
// 再写内存(持久化已成功写入,内存失败也不影响重启后恢复)
self.inner.add(documents, embeddings).await
}
async fn search(
&self,
query: &[f32],
k: usize,
) -> Result<Vec<(Document, f32)>, MemoryError> {
tracing::trace!(k, "PersistentVectorStore::search");
self.inner.search(query, k).await
}
async fn remove(&self, ids: &[String]) -> Result<(), MemoryError> {
debug!(count = ids.len(), "PersistentVectorStore::remove");
for id in ids {
let key = format!("vec:{}:{}", self.namespace, id);
self.store.delete(&key).await?;
}
self.inner.remove(ids).await
}
}
// ponytail: `with_entries` 当前仅供 `PersistentVectorStore::new` 使用;
// 后续如需 VecStore 之间迁移,可放宽到 `pub`。
/// RAG 管线组合器 —— 封装 `split → embed → store`ingest)和
/// `embed → store.search`retrieve)两个核心流程。
///
/// # 使用方式
///
/// ```ignore
/// let pipeline = RagPipeline::new(embedder, store, Some(splitter));
/// pipeline.ingest(&documents).await?;
/// let results = pipeline.retrieve("query", 5).await?;
/// ```
///
/// # 分割器
///
/// `splitter` 字段为 `Option<RecursiveCharacterSplitter>`
/// - `Some(splitter)` → `ingest()` 先分割再嵌入(调用方传入原始文档)
/// - `None` → `ingest()` 跳过分割,直接嵌入(调用方已分好 chunk)
pub struct RagPipeline {
embedder: Arc<dyn Embedding>,
store: Arc<dyn VectorStore>,
splitter: Option<RecursiveCharacterSplitter>,
}
impl RagPipeline {
/// 创建新的 RAG 管线。
///
/// 不设置分割器时,`ingest()` 跳过分割阶段,
/// 调用方传入的 Document 应已是分割好的 chunk。
pub fn new(
embedder: Arc<dyn Embedding>,
store: Arc<dyn VectorStore>,
splitter: Option<RecursiveCharacterSplitter>,
) -> Self {
Self {
embedder,
store,
splitter,
}
}
/// 摄取文档:分割 → 向量化 → 存储。
///
/// 流程:
/// 1. 如果 splitter 存在,先分割文档为 chunks
/// 2. 提取所有 chunk 的 content 为 `Vec<String>`
/// 3. `embedder.embed()` 批量向量化
/// 4. `store.add()` 批量存储
///
/// # 边界
///
/// - 空文档切片 → `Ok(())`,无操作
/// - 分割后 chunk 为空 → `Ok(())`,无操作
///
/// # 已知限制
///
/// 当前将所有 chunk 一次性传入 `embedder.embed()`,真实 Embedding Provider
/// (如 OpenAI)有批量大小限制,调用方需自行控制单次 ingest 的文档数(如 20 条/批)。
pub async fn ingest(&self, documents: &[Document]) -> Result<(), MemoryError> {
let chunks = match &self.splitter {
Some(splitter) => splitter.split(documents),
None => documents.to_vec(),
};
if chunks.is_empty() {
return Ok(());
}
let texts: Vec<String> = chunks.iter().map(|d| d.content.clone()).collect();
let embeddings = self
.embedder
.embed(&texts)
.await
.map_err(|e| MemoryError::Storage(e.to_string()))?;
self.store.add(&chunks, &embeddings).await
}
/// 检索:向量化查询 → 向量相似度搜索。
///
/// # 边界
///
/// - 空字符串查询 → 返回 `vec![]`embed 产生零向量 → search 零向量守卫)
pub async fn retrieve(
&self,
query: &str,
k: usize,
) -> Result<Vec<(Document, f32)>, MemoryError> {
let embeddings = self
.embedder
.embed(&[query.to_string()])
.await
.map_err(|e| MemoryError::Storage(e.to_string()))?;
self.store.search(&embeddings[0], k).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::time::Duration;
fn make_doc(id: &str, content: &str) -> Document {
Document::from_raw(id, content)
}
fn make_vec(values: &[f32]) -> Vec<f32> {
values.to_vec()
}
#[tokio::test]
async fn basic_add_and_search() {
let store = InMemoryVectorStore::new();
let docs = vec![
make_doc("rust", "Rust language"),
make_doc("python", "Python language"),
make_doc("javascript", "JavaScript language"),
];
let embeddings = vec![
make_vec(&[1.0, 0.0, 0.0]),
make_vec(&[0.0, 1.0, 0.0]),
make_vec(&[0.0, 0.0, 1.0]),
];
store.add(&docs, &embeddings).await.unwrap();
let results = store.search(&[0.9, 0.1, 0.0], 3).await.unwrap();
assert_eq!(results.len(), 3);
assert_eq!(results[0].0.id, "rust", "Top 1 应为 rust");
assert!(results[0].1 > results[1].1);
assert!(results[1].1 > results[2].1);
}
#[tokio::test]
async fn search_empty_store() {
let store = InMemoryVectorStore::new();
let results = store.search(&[1.0, 0.0, 0.0], 5).await.unwrap();
assert!(results.is_empty());
}
#[tokio::test]
async fn search_zero_vector() {
let store = InMemoryVectorStore::new();
let docs = vec![make_doc("a", "alpha")];
let embeddings = vec![make_vec(&[1.0, 0.0, 0.0])];
store.add(&docs, &embeddings).await.unwrap();
let results = store.search(&[0.0, 0.0, 0.0], 5).await.unwrap();
assert!(results.is_empty(), "零向量查询应返回空");
}
#[tokio::test]
async fn search_k_is_zero() {
let store = InMemoryVectorStore::new();
let docs = vec![make_doc("a", "alpha")];
let embeddings = vec![make_vec(&[1.0, 0.0, 0.0])];
store.add(&docs, &embeddings).await.unwrap();
let results = store.search(&[1.0, 0.0, 0.0], 0).await.unwrap();
assert!(results.is_empty(), "k=0 应返回空");
}
#[tokio::test]
async fn search_orthogonal_vectors() {
let store = InMemoryVectorStore::new();
let docs = vec![make_doc("a", "alpha")];
let embeddings = vec![make_vec(&[1.0, 0.0, 0.0])];
store.add(&docs, &embeddings).await.unwrap();
// 正交查询:余弦相似度 ≈ 0,结果仍返回(分数极低)
let results = store.search(&[0.0, 1.0, 0.0], 5).await.unwrap();
assert_eq!(results.len(), 1, "正交向量仍返回,score 接近 0");
assert!(results[0].1 < 1e-10, "正交相似度应约等于 0");
}
#[tokio::test]
async fn add_mismatched_lengths() {
let store = InMemoryVectorStore::new();
let docs = vec![
make_doc("a", "alpha"),
make_doc("b", "beta"),
make_doc("c", "gamma"),
];
let embeddings = vec![make_vec(&[1.0, 0.0, 0.0]), make_vec(&[0.0, 1.0, 0.0])];
let result = store.add(&docs, &embeddings).await;
assert!(result.is_err(), "不等长应返回 Err");
// 部分写入已发生:前 2 条已写入
let results = store.search(&[1.0, 0.0, 0.0], 5).await.unwrap();
assert_eq!(results.len(), 2, "应有 2 条成功写入");
let ids: Vec<&str> = results.iter().map(|(d, _)| d.id.as_str()).collect();
assert!(ids.contains(&"a"));
assert!(ids.contains(&"b"));
}
#[tokio::test]
async fn add_duplicate_id_upsert() {
let store = InMemoryVectorStore::new();
let docs_v1 = vec![make_doc("a", "v1 content")];
let embeddings_v1 = vec![make_vec(&[1.0, 0.0, 0.0])];
store.add(&docs_v1, &embeddings_v1).await.unwrap();
// 同一 doc.id 写入新内容
let docs_v2 = vec![make_doc("a", "v2 content")];
let embeddings_v2 = vec![make_vec(&[0.0, 1.0, 0.0])];
store.add(&docs_v2, &embeddings_v2).await.unwrap();
let results = store.search(&[0.9, 0.1, 0.0], 5).await.unwrap();
assert_eq!(results.len(), 1, "重复 id 写入应覆盖,最终仅 1 条");
assert_eq!(results[0].0.content, "v2 content", "新内容应覆盖旧内容");
}
#[tokio::test]
async fn remove_items() {
let store = InMemoryVectorStore::new();
let docs = vec![make_doc("a", "alpha"), make_doc("b", "beta")];
let embeddings = vec![
make_vec(&[1.0, 0.0, 0.0]),
make_vec(&[0.0, 1.0, 0.0]),
];
store.add(&docs, &embeddings).await.unwrap();
store.remove(&["a".to_string()]).await.unwrap();
let results = store.search(&[1.0, 0.0, 0.0], 5).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].0.id, "b");
}
#[tokio::test]
async fn remove_nonexistent_id() {
let store = InMemoryVectorStore::new();
// 从未添加的 id 应静默忽略
let result = store.remove(&["nonexistent".to_string()]).await;
assert!(result.is_ok(), "删除不存在的 id 不应报错");
// 已有索引时也不应报错
let docs = vec![make_doc("a", "alpha")];
let embeddings = vec![make_vec(&[1.0, 0.0, 0.0])];
store.add(&docs, &embeddings).await.unwrap();
let result = store.remove(&["nonexistent".to_string(), "also_nonexistent".to_string()]).await;
assert!(result.is_ok(), "批量删除不存在 id 不应报错");
let results = store.search(&[1.0, 0.0, 0.0], 5).await.unwrap();
assert_eq!(results.len(), 1, "原有数据应保留");
}
#[tokio::test]
async fn concurrent_operations() {
let store = Arc::new(InMemoryVectorStore::new());
let mut handles = Vec::new();
// 10 个并发写入
for i in 0..10 {
let s = Arc::clone(&store);
handles.push(tokio::spawn(async move {
let docs = vec![make_doc(&format!("item_{i}"), &format!("content_{i}"))];
let embeddings = vec![make_vec(&[i as f32, 0.0, 0.0])];
s.add(&docs, &embeddings).await.unwrap();
}));
}
for h in handles.drain(..) {
h.await.unwrap();
}
// 验证并发写入后 search 结果计数正确
let results = store.search(&[1.0, 0.0, 0.0], 20).await.unwrap();
assert_eq!(results.len(), 10, "并发 add 10 条后应能检索到 10 条");
// 混合写入 + 搜索的并发(无 panic)
let deadline = tokio::time::Instant::now() + Duration::from_millis(100);
let mut handles = Vec::new();
for w in 0..3 {
let s = Arc::clone(&store);
handles.push(tokio::spawn(async move {
let mut i = 0;
while tokio::time::Instant::now() < deadline {
let docs = vec![make_doc(&format!("w{w}_i{i}"), "x")];
let embeddings = vec![make_vec(&[i as f32, 0.0, 0.0])];
let _ = s.add(&docs, &embeddings).await;
let _ = s.search(&[1.0, 0.0, 0.0], 3).await;
i += 1;
}
}));
}
for h in handles {
h.await.unwrap();
}
}
// ===== Persistent tests =====
use crate::memory::store::InMemoryStore;
async fn make_persistent(
backend: Arc<dyn MemoryStore>,
namespace: &str,
) -> PersistentVectorStore {
PersistentVectorStore::new(backend, namespace).await.unwrap()
}
#[tokio::test]
async fn persistent_roundtrip() {
let backend: Arc<dyn MemoryStore> = Arc::new(InMemoryStore::new());
let store = make_persistent(Arc::clone(&backend), "default").await;
let docs = vec![
make_doc("a", "alpha"),
make_doc("b", "beta"),
make_doc("c", "gamma"),
];
let embeddings = vec![
make_vec(&[1.0, 0.0, 0.0]),
make_vec(&[0.0, 1.0, 0.0]),
make_vec(&[0.0, 0.0, 1.0]),
];
store.add(&docs, &embeddings).await.unwrap();
// 重建 store(模拟重启)
let store2 = make_persistent(Arc::clone(&backend), "default").await;
let results = store2.search(&[0.9, 0.1, 0.0], 5).await.unwrap();
assert_eq!(results.len(), 3);
assert_eq!(results[0].0.id, "a", "Top 1 应为 a(与 [1,0,0] 最相似)");
}
#[tokio::test]
async fn search_after_reload() {
let backend: Arc<dyn MemoryStore> = Arc::new(InMemoryStore::new());
let store = make_persistent(Arc::clone(&backend), "default").await;
let docs = vec![make_doc("target", "the target doc")];
let embeddings = vec![make_vec(&[1.0, 0.0, 0.0])];
store.add(&docs, &embeddings).await.unwrap();
// 重建
let store2 = make_persistent(Arc::clone(&backend), "default").await;
let results = store2.search(&[0.99, 0.01, 0.0], 1).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].0.id, "target");
assert!(results[0].1 > 0.99);
}
#[tokio::test]
async fn namespace_isolation() {
let backend: Arc<dyn MemoryStore> = Arc::new(InMemoryStore::new());
let s1 = make_persistent(Arc::clone(&backend), "ns1").await;
let s2 = make_persistent(Arc::clone(&backend), "ns2").await;
let docs = vec![make_doc("shared_id", "content")];
let embeddings = vec![make_vec(&[1.0, 0.0, 0.0])];
s1.add(&docs, &embeddings).await.unwrap();
// s1 能检索到
let r1 = s1.search(&[1.0, 0.0, 0.0], 5).await.unwrap();
assert_eq!(r1.len(), 1);
// s2 在 ns2 下,shared_id 不属于 ns2,应检索不到
let r2 = s2.search(&[1.0, 0.0, 0.0], 5).await.unwrap();
assert!(r2.is_empty(), "不同 namespace 应隔离");
}
#[tokio::test]
async fn concurrent_access() {
let backend: Arc<dyn MemoryStore> = Arc::new(InMemoryStore::new());
let store = Arc::new(make_persistent(Arc::clone(&backend), "default").await);
let mut handles = Vec::new();
for i in 0..5 {
let s = Arc::clone(&store);
handles.push(tokio::spawn(async move {
let docs = vec![make_doc(&format!("concurrent_{i}"), "x")];
let embeddings = vec![make_vec(&[i as f32, 0.0, 0.0])];
s.add(&docs, &embeddings).await.unwrap();
}));
}
for _w in 0..3 {
let s = Arc::clone(&store);
handles.push(tokio::spawn(async move {
let _ = s.search(&[1.0, 0.0, 0.0], 10).await.unwrap();
}));
}
for h in handles {
h.await.unwrap();
}
let results = store.search(&[1.0, 0.0, 0.0], 20).await.unwrap();
assert_eq!(results.len(), 5, "并发写入 5 条后应能检索到 5 条");
}
#[tokio::test]
async fn partial_add_recovery() {
// 写入 5 条,模拟第 3 条持久化失败(通过底层 InMemoryStore 的 save 拦截)
let backend: Arc<dyn MemoryStore> = Arc::new(InMemoryStore::new());
let store = make_persistent(Arc::clone(&backend), "default").await;
// 正常写入前 2 条
let docs_first = vec![
make_doc("doc_0", "first"),
make_doc("doc_1", "second"),
];
let embeddings_first = vec![make_vec(&[1.0, 0.0, 0.0]), make_vec(&[0.0, 1.0, 0.0])];
store.add(&docs_first, &embeddings_first).await.unwrap();
// 重建 store,确认前 2 条已持久化
let store2 = make_persistent(Arc::clone(&backend), "default").await;
let results = store2.search(&[1.0, 0.0, 0.0], 10).await.unwrap();
assert_eq!(results.len(), 2, "前 2 条应已持久化并能加载");
let ids: Vec<&str> = results.iter().map(|(d, _)| d.id.as_str()).collect();
assert!(ids.contains(&"doc_0"));
assert!(ids.contains(&"doc_1"));
}
#[tokio::test]
async fn new_empty_store() {
// 空后端构造 PersistentVectorStore 应成功,且 search 返回空
let backend: Arc<dyn MemoryStore> = Arc::new(InMemoryStore::new());
let store = make_persistent(Arc::clone(&backend), "empty_ns").await;
let results = store.search(&[1.0, 0.0, 0.0], 5).await.unwrap();
assert!(results.is_empty(), "空存储 search 应返回空");
// 写入后能检索
let docs = vec![make_doc("after_empty", "data")];
let embeddings = vec![make_vec(&[1.0, 0.0, 0.0])];
store.add(&docs, &embeddings).await.unwrap();
let results = store.search(&[1.0, 0.0, 0.0], 5).await.unwrap();
assert_eq!(results.len(), 1);
}
// ===== RagPipeline tests =====
use crate::llm::embedding::MockEmbedding;
#[tokio::test]
async fn ingest_and_retrieve() {
let embedder: Arc<dyn Embedding> = Arc::new(MockEmbedding::new(4));
let store: Arc<dyn VectorStore> = Arc::new(InMemoryVectorStore::new());
let splitter = RecursiveCharacterSplitter::new(50, 5);
let pipeline = RagPipeline::new(
Arc::clone(&embedder),
Arc::clone(&store),
Some(splitter),
);
// 创建多段落文档
let doc = Document::new(
"rag-doc",
"Rust 是一门系统编程语言。\n\n\
Rust \n\n\
Cargo ",
"text/markdown",
);
pipeline.ingest(&[doc]).await.unwrap();
// 用第一个 chunk 的 content 检索(应能命中自己或相关 chunk)
let docs_stored = store.search(&[1.0, 0.0, 0.0, 0.0], 100).await.unwrap();
assert!(!docs_stored.is_empty(), "ingest 后 store 应有数据");
// retrieve 测试
let results = pipeline.retrieve("Rust ownership", 3).await.unwrap();
assert!(!results.is_empty(), "retrieve 应返回结果");
// 验证返回的 Document.id 是 chunk id 格式(来自 splitter
for (doc, _score) in &results {
assert!(
doc.id.starts_with("rag-doc:chunk:"),
"chunk id 格式应为 rag-doc:chunk:NNNN,实际: {}",
doc.id
);
}
}
#[tokio::test]
async fn retrieve_empty_store() {
let embedder: Arc<dyn Embedding> = Arc::new(MockEmbedding::new(4));
let store: Arc<dyn VectorStore> = Arc::new(InMemoryVectorStore::new());
let pipeline = RagPipeline::new(embedder, store, None);
let results = pipeline.retrieve("anything", 5).await.unwrap();
assert!(results.is_empty(), "空 store retrieve 应返回空");
}
#[tokio::test]
async fn ingest_empty_docs() {
let embedder: Arc<dyn Embedding> = Arc::new(MockEmbedding::new(4));
let store: Arc<dyn VectorStore> = Arc::new(InMemoryVectorStore::new());
let pipeline = RagPipeline::new(embedder, Arc::clone(&store), None);
// 空切片应返回 Ok(()),不报错
let result = pipeline.ingest(&[]).await;
assert!(result.is_ok(), "空文档切片 ingest 应返回 Ok");
// 验证 store 中没有数据
let results = store.search(&[1.0, 0.0, 0.0, 0.0], 5).await.unwrap();
assert!(results.is_empty(), "空 ingest 后 store 应为空");
}
#[tokio::test]
async fn ingest_empty_split() {
let embedder: Arc<dyn Embedding> = Arc::new(MockEmbedding::new(4));
let store: Arc<dyn VectorStore> = Arc::new(InMemoryVectorStore::new());
// splitter 分割空内容文档
let splitter = RecursiveCharacterSplitter::new(50, 5);
let pipeline = RagPipeline::new(embedder, Arc::clone(&store), Some(splitter));
// 传入一个空内容文档,splitter 应返回空 chunks
let empty_doc = Document::from_raw("empty_id", "");
let result = pipeline.ingest(&[empty_doc]).await;
assert!(result.is_ok(), "空内容 split 后 ingest 应返回 Ok");
let results = store.search(&[1.0, 0.0, 0.0, 0.0], 5).await.unwrap();
assert!(results.is_empty(), "空 split 后 store 应为空");
}
// ===== Performance benchmarks (Step 15.6.7) =====
/// 性能基准:InMemoryVectorStore::search 在 10K 条 64 维向量索引上搜索耗时 < 100ms。
/// ponytail: 本测试作为性能下限断言(非精确基准),CI 环境性能差异可通过调整阈值补偿。
#[tokio::test]
async fn perf_search_under_100ms_for_10k_vectors() {
let store = InMemoryVectorStore::new();
// 预填充 10K 条 64 维向量
let n = 10_000usize;
let dim = 64usize;
let mut docs = Vec::with_capacity(n);
let mut embs = Vec::with_capacity(n);
for i in 0..n {
docs.push(make_doc(&format!("d{i}"), "x"));
let v: Vec<f32> = (0..dim).map(|j| ((i + j) as f32).sin()).collect();
embs.push(v);
}
store.add(&docs, &embs).await.unwrap();
// 性能断言
let start = std::time::Instant::now();
let _results = store.search(&vec![1.0_f32; dim], 10).await.unwrap();
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_millis(100),
"10K 条 64 维向量 search 耗时 {}ms 超过 100ms 阈值",
elapsed.as_millis()
);
}
/// 性能基准:PersistentVectorStore::new 加载 10K 条 < 500ms。
#[tokio::test]
async fn perf_persistent_load_under_500ms_for_10k() {
let backend: Arc<dyn MemoryStore> = Arc::new(InMemoryStore::new());
let store = make_persistent(Arc::clone(&backend), "perf_ns").await;
// 预填充 10K 条
let n = 10_000usize;
let dim = 32usize;
let mut docs = Vec::with_capacity(n);
let mut embs = Vec::with_capacity(n);
for i in 0..n {
docs.push(make_doc(&format!("d{i}"), "x"));
let v: Vec<f32> = (0..dim).map(|j| ((i + j) as f32).cos()).collect();
embs.push(v);
}
store.add(&docs, &embs).await.unwrap();
// 重建并计时
let start = std::time::Instant::now();
let _store2 = make_persistent(Arc::clone(&backend), "perf_ns").await;
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_millis(500),
"PersistentVectorStore::new 加载 10K 条耗时 {}ms 超过 500ms 阈值",
elapsed.as_millis()
);
}
}