コンテキスト圧縮の設計更新

This commit is contained in:
2026-04-12 04:47:42 +09:00
parent 0e7a7b02fe
commit dc1a335e1c
2 changed files with 311 additions and 426 deletions
+133 -105
View File
@@ -1,132 +1,160 @@
# ツール出力の遅延読み込み設計
# ツール出力の設計
## 課題
ツール実行結果(ファイル内容、検索結果等)は サイズが予測不能 で、
全量を `Item::ToolResult { output: String }` として LLM コンテキストに
載せると、トークン消費が爆発する。
ツール実行結果(ファイル内容、検索結果等)はサイズが予測不能で、
全量を LLM コンテキストに載せるとトークン消費が爆発する。
## 方針
- ツール出力 **Inline / Stored** の区別を導入する
- Stored な出力は **BlobStore** に保存し、履歴には要約のみ載せる
- LLM が詳細を見たい場合は **inspect ツール** で部分取得す
ツール出力 **summary(常駐)****contentprunable** の2フィールドに分離する
- summary: 1-2行。常に history に残る。Prune 後もこれだけで「何をしたか」がわか
- content: 詳細な出力。一定閾値まで。Prune で消える
巨大な出力(大量の grep 結果、巨大ファイル等)はフレームワークの責務外。
ツール側がファイルに書き出し、content に見取り図を置く。
## データ型
### ToolOutputllm-worker 側)
### ToolOutput
```rust
pub enum ToolOutput {
/// 小さな結果: そのまま history に載る
Inline(String),
/// 大きな結果: summary だけ history に載り、全体は BlobStore に保存される
Stored {
summary: String,
content: Content,
},
}
/// ツール実行結果。
///
/// summary は常に必須。content は省略可能。
/// Prune 時に content が除去され、summary だけが残る。
pub struct ToolOutput {
/// 1-2行の要約。Prune 後も history に残る。
/// 例: "read_file: src/main.rs — 42 lines"
/// 例: "bash: cargo test — exit 0, 3 passed"
/// 例: "grep: TODO in src/ — 128 hits, saved to /tmp/grep_result.txt"
pub summary: String,
pub enum Content {
Text(String),
Structured(serde_json::Value),
/// 詳細な出力内容。Prune で消える。
/// None の場合、summary のみが history に載る。
pub content: Option<String>,
}
```
- `Tool::execute()` の戻り値は `Result<String, ToolError>` のまま据え置き
- `From<String> for ToolOutput` で閾値ベースの自動昇格を行う
- ツール実装者が明示的に `ToolOutput` を返したい場合は別トレイトメソッドを用意
### BlobStorellm-worker-persistence 側)
### Item::ToolResult
```rust
pub type BlobId = uuid::Uuid; // UUID v7
pub trait BlobStore: Send + Sync {
fn store(&self, content: &Content) -> impl Future<Output = Result<BlobId, BlobStoreError>> + Send;
fn load(&self, id: BlobId) -> impl Future<Output = Result<Content, BlobStoreError>> + Send;
fn exists(&self, id: BlobId) -> impl Future<Output = Result<bool, BlobStoreError>> + Send;
Item::ToolResult {
id: Option<ItemId>,
call_id: CallId,
/// 1-2行の要約。Prune 後も残る。
summary: String,
/// 詳細な出力。Prune で None に置換される。
content: Option<String>,
}
```
### FsBlobStore レイアウト
LLM への送信時は summary + content を結合して単一文字列にする。
content が None の場合は summary のみ。
```
blobs/
├── {blob_id}.txt # Content::Text
└── {blob_id}.json # Content::Structured
```rust
impl Item {
/// LLM に送信する出力文字列を構築。
pub fn tool_result_text(&self) -> Option<&str> {
match self {
Item::ToolResult { summary, content: Some(c), .. } => {
// 呼び出し側で結合
None // 実際は format!("{summary}\n{c}")
}
Item::ToolResult { summary, content: None, .. } => Some(summary),
_ => None,
}
}
}
```
セッションとは独立したフラットなストア。セッションとの紐付けは
ログ側の参照(summary 内の `[blob:<id>]`)で行う。
### Tool trait の変更
## 自動サマリ
`Tool::execute()` の戻り値を `Result<ToolOutput, ToolError>` に変更する。
`From<String>` による自動昇格時のサマリ生成ルール:
```rust
#[async_trait]
pub trait Tool: Send + Sync {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError>;
}
```
| 項目 | 値 |
ツールが独自の summary を付けたい場合は `ToolOutput` を直接構築する。
単純なケースでは `From<String>` で自動変換できる: `Ok("result".to_string().into())`
### From\<String\> 変換
`From<String>` による自動変換:
```rust
impl From<String> for ToolOutput {
fn from(s: String) -> Self {
if s.len() <= SUMMARY_THRESHOLD {
// 小さい出力: summary のみ(content なし)
ToolOutput { summary: s, content: None }
} else {
// summary = 先頭行 + メタ情報
let lines = s.lines().count();
let first_line: String = s.lines().next()
.unwrap_or("")
.chars().take(80)
.collect();
let summary = format!("{lines} lines | {first_line}");
ToolOutput { summary, content: Some(s) }
}
}
}
```
`SUMMARY_THRESHOLD`: summary のみで十分な小さい出力の閾値。
具体値は調整するが、数百バイト程度を想定。
## Prune との関係
```
ツール実行
→ ToolOutput { summary, content }
→ Item::ToolResult { summary, content } ← history に追加
─── 数ターン経過 ───
Prunepre_llm_request フック)
→ Item::ToolResult { summary, content: None } ← content を除去
```
Prune の実装は `content = None` にするだけ。
prunable トークン数の推定:
- `content.as_ref().map(|c| c.len() / 4).unwrap_or(0)`
## 巨大出力の扱い
フレームワークは巨大出力を特別扱いしない。
ツール側が自分で判断して対処する。
```
巨大な grep 結果 → ツールがファイルに書き出す
→ summary: "grep: TODO in src/ — 128 hits"
→ content: ファイルパス + ヒット数の内訳(見取り図)
巨大なファイル読み取り → ツールが部分読み取りを提案
→ summary: "read_file: data.csv — 50,000 lines"
→ content: 先頭 N 行 + 末尾 M 行
```
LLM が詳細を見たい場合は、read_file / grep 等の汎用ツールで
ファイルを直接参照する。専用の inspect ツールは不要。
## 削除対象(旧設計からの移行)
| モジュール | 理由 |
|---|---|
| Inline 閾値 | 800 bytes |
| サマリ上限 | 400 bytes |
| 先頭行数 | 5 行 |
| 末尾行数 | 3 行 |
### Text のサマリ形式
```
[blob:<id>] text | {N} lines
── head ──
{先頭5行}
── tail ──
{末尾3行}
```
### Structured (JSON Array) のサマリ形式
```
[blob:<id>] json_array | {N} entries
── schema ──
{最初の要素のキー: 型}
── head ──
{先頭2要素}
```
### Structured (JSON Object) のサマリ形式
```
[blob:<id>] json_object | {N} keys
── keys ──
{キー一覧と各値の型/サイズ}
```
## Worker への統合
```
Tool::execute() → Result<String, ToolError>
▼ From<String> for ToolOutput
ToolOutput::Inline(s) ← len ≤ 800
ToolOutput::Stored { .. } ← len > 800
▼ Worker が BlobStore に保存
Item::ToolResult { output: summary } ← history に載る
▼ LLM が詳細を見たい場合
inspect(blob_id, selector?) → 部分取得
```
Worker はオプショナルに `BlobStore` を保持する。
BlobStore が未設定の場合は従来通り全量 Inline として扱う。
## inspect ツール
Worker に BlobStore が設定されている場合、自動的に登録される組み込みツール。
```
inspect(blob_id, selector?)
```
- selector 省略: メタ情報 + 先頭部分
- `lines:20-50`: 行範囲(Text 用)
- `slice:3..8`: インデックス範囲(Array 用)
- `key:results`: キー指定(Object 用)
| `ToolOutput` enumInline/Stored | struct に置換 |
| `Content` enumText/Structured | 不要 |
| `auto_summarize` / `auto_summarize_text` / `auto_summarize_structured` | 不要 |
| `ToolOutputProcessor` trait | 不要 |
| `BlobOutputProcessor` | 不要 |
| `BlobStore` trait / `FsBlobStore` | 不要 |
| `inspect_tool.rs` | 不要 |
| Worker の `output_processor` フィールド | 不要 |