Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32d886f870 | ||
|
|
b04427e83f | ||
|
|
d4c4d8fa3c | ||
|
|
4686063ca8 | ||
|
|
c36668071e | ||
|
|
d4f27b5865 | ||
|
|
1c0e1e0ed1 | ||
|
|
760de46623 | ||
|
|
f8df6a9421 | ||
|
|
802518b5fe | ||
|
|
993118f661 |
@@ -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 个面向新用户的集成示例。
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
**验证**:
|
**验证**:
|
||||||
|
|
||||||
|
|||||||
@@ -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 13(v0.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.1(ToolChoice 不搬走,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-merge(child.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 消息内容、血缘关系不变)。
|
||||||
|
|
||||||
|
**预估工作量**:M(1-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.4(ToolChoice 已移走,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` 文件
|
||||||
|
|
||||||
|
**预估工作量**:M(1-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` 文件
|
||||||
|
|
||||||
|
**预估工作量**:M(1-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(<1h,cleanup)+ 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
@@ -1,13 +1,13 @@
|
|||||||
# AG Core Roadmap
|
# AG Core Roadmap
|
||||||
|
|
||||||
> 定稿日期:2026-05-11
|
> 定稿日期:2026-05-11
|
||||||
> 最后更新:2026-07-06
|
> 最后更新:2026-07-09(Phase 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 个增量 Phase(Phase 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 / metadata(HashMap<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` trait(async + `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 14(Document 类型)
|
||||||
|
**优先级**: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 10(ContextSlot 持久化 — 消息由 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 17(SessionManager + 会话树)
|
||||||
|
**优先级**: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` 等新公开 API,v0.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. **里程碑追踪**:以 M10(Phase 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 warning,11 个离线示例全部 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 warning,11 个离线示例全部 exit 0
|
||||||
- ✅ **Phase 11 测试与检索补强** — `src/memory/vector.rs` 新增 `VectorRetriever` trait(index + 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` trait(index + 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-export;9 个 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 个增量 Phase(Phase 5-12),17 个可验证 Step,覆盖 P0-P2 全部 12 项功能 + ContextSlot
|
- ✅ **v0.2 规划细化完成** — 8 个增量 Phase(Phase 5-12),17 个可验证 Step,覆盖 P0-P2 全部 12 项功能 + ContextSlot
|
||||||
|
- ✅ **v0.3.0 Phase 13 完成** — 技术债清理(3 旧 types 文件 + ChatResponse 删除)+ ContextSlot fork/merge(9 新测试),M9 里程碑达成
|
||||||
|
- ✅ **v0.3.0 Phase 14 完成** — Document 类型(id/content/metadata/mime_type)+ `RecursiveCharacterSplitter` 两阶段算法(按 separator 优先级递归分割 + 贪心合并 overlap,全部 `chars_len()` 字符级比较)+ `Embedding` trait(async + `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 待实施(向量存储 → 摘要 → 引擎 → 调度 → 知识图谱)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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};
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(¤t) >= 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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 hook(fire-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 {
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
//! Embedding 抽象 —— 文本向量化接口。
|
||||||
|
//!
|
||||||
|
//! 提供 [`Embedding`] trait 和零依赖的 [`MockEmbedding`] 引用实现。
|
||||||
|
//! 上层可实现此 trait 以对接真实 Embedding Provider(OpenAI、Cohere 等)。
|
||||||
|
//!
|
||||||
|
//! 所有实现使用 [`LlmError`] 作为统一错误类型,与 llm 模块保持一致。
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::llm::error::LlmError;
|
||||||
|
|
||||||
|
/// 文本向量化抽象接口。
|
||||||
|
///
|
||||||
|
/// 将文本字符串转换为固定维度的浮点向量,用于语义相似度计算。
|
||||||
|
/// 设计为异步以支持网络 IO(如 OpenAI Embedding API)。
|
||||||
|
///
|
||||||
|
/// 使用 [`LlmError`] 作为统一错误类型,与 llm 模块保持一致。
|
||||||
|
///
|
||||||
|
/// # 实现要求
|
||||||
|
///
|
||||||
|
/// - `embed()` 返回的向量外层的 Vec 长度必须等于输入切片长度(一对一映射)
|
||||||
|
/// - 内层 Vec 长度必须等于 `dim()` 返回值
|
||||||
|
/// - 调用方应保证输入非空(空切片返回空外层 Vec,不报错)
|
||||||
|
///
|
||||||
|
/// # 稳定性
|
||||||
|
///
|
||||||
|
/// 实验性 API(v0.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` 在维度较高时(如 1536,OpenAI 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
@@ -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
@@ -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) => {
|
|
||||||
// 旧流结束 → 主动补一个 MessageComplete(full_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
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>,
|
|
||||||
}
|
|
||||||
@@ -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 无关的请求类型。
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use crate::memory::error::MemoryError;
|
|||||||
///
|
///
|
||||||
/// **稳定性**:实验性 API(v0.2.x),方法签名可能在 v0.3 中调整。
|
/// **稳定性**:实验性 API(v0.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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user