mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-02-20 03:00:31 -05:00
New Memory Improvements (#4484)
* better DevEx * Refactor: Update supported native providers and enhance memory handling - Removed "groq" and "meta" from the list of supported native providers in `llm.py`. - Added a safeguard in `flow.py` to ensure all background memory saves complete before returning. - Improved error handling in `unified_memory.py` to prevent exceptions during shutdown, ensuring smoother memory operations and event bus interactions. * Enhance Memory System with Consolidation and Learning Features - Introduced memory consolidation mechanisms to prevent duplicate records during content saving, utilizing similarity checks and LLM decision-making. - Implemented non-blocking save operations in the memory system, allowing agents to continue tasks while memory is being saved. - Added support for learning from human feedback, enabling the system to distill lessons from past corrections and improve future outputs. - Updated documentation to reflect new features and usage examples for memory consolidation and HITL learning. * Enhance cyclic flow handling for or_() listeners - Updated the Flow class to ensure that all fired or_() listeners are cleared between cycle iterations, allowing them to fire again in subsequent cycles. This change addresses a bug where listeners remained suppressed across iterations. - Added regression tests to verify that or_() listeners fire correctly on every iteration in cyclic flows, ensuring expected behavior in complex routing scenarios.
This commit is contained in:
@@ -380,22 +380,124 @@ Memory uses the LLM in three ways:
|
||||
All analysis degrades gracefully on LLM failure -- see [Failure Behavior](#failure-behavior).
|
||||
|
||||
|
||||
## RecallFlow (Deep Recall)
|
||||
## Memory Consolidation
|
||||
|
||||
`recall()` supports three depths:
|
||||
When saving new content, the encoding pipeline automatically checks for similar existing records in storage. If the similarity is above `consolidation_threshold` (default 0.85), the LLM decides what to do:
|
||||
|
||||
- **`depth="shallow"`** -- Direct vector search with composite scoring. Fast; used by default when agents load context.
|
||||
- **`depth="deep"` or `depth="auto"`** -- Runs a multi-step RecallFlow: query analysis, scope selection, vector search, confidence-based routing, and optional recursive exploration when confidence is low.
|
||||
- **keep** -- The existing record is still accurate and not redundant.
|
||||
- **update** -- The existing record should be updated with new information (LLM provides the merged content).
|
||||
- **delete** -- The existing record is outdated, superseded, or contradicted.
|
||||
- **insert_new** -- Whether the new content should also be inserted as a separate record.
|
||||
|
||||
This prevents duplicates from accumulating. For example, if you save "CrewAI ensures reliable operation" three times, consolidation recognizes the duplicates and keeps only one record.
|
||||
|
||||
### Intra-batch Dedup
|
||||
|
||||
When using `remember_many()`, items within the same batch are compared against each other before hitting storage. If two items have cosine similarity >= `batch_dedup_threshold` (default 0.98), the later one is silently dropped. This catches exact or near-exact duplicates within a single batch without any LLM calls (pure vector math).
|
||||
|
||||
```python
|
||||
# Fast path (default for agent task context)
|
||||
# Only 2 records are stored (the third is a near-duplicate of the first)
|
||||
memory.remember_many([
|
||||
"CrewAI supports complex workflows.",
|
||||
"Python is a great language.",
|
||||
"CrewAI supports complex workflows.", # dropped by intra-batch dedup
|
||||
])
|
||||
```
|
||||
|
||||
|
||||
## Non-blocking Saves
|
||||
|
||||
`remember_many()` is **non-blocking** -- it submits the encoding pipeline to a background thread and returns immediately. This means the agent can continue to the next task while memories are being saved.
|
||||
|
||||
```python
|
||||
# Returns immediately -- save happens in background
|
||||
memory.remember_many(["Fact A.", "Fact B.", "Fact C."])
|
||||
|
||||
# recall() automatically waits for pending saves before searching
|
||||
matches = memory.recall("facts") # sees all 3 records
|
||||
```
|
||||
|
||||
### Read Barrier
|
||||
|
||||
Every `recall()` call automatically calls `drain_writes()` before searching, ensuring the query always sees the latest persisted records. This is transparent -- you never need to think about it.
|
||||
|
||||
### Crew Shutdown
|
||||
|
||||
When a crew finishes, `kickoff()` drains all pending memory saves in its `finally` block, so no saves are lost even if the crew completes while background saves are in flight.
|
||||
|
||||
### Standalone Usage
|
||||
|
||||
For scripts or notebooks where there's no crew lifecycle, call `drain_writes()` or `close()` explicitly:
|
||||
|
||||
```python
|
||||
memory = Memory()
|
||||
memory.remember_many(["Fact A.", "Fact B."])
|
||||
|
||||
# Option 1: Wait for pending saves
|
||||
memory.drain_writes()
|
||||
|
||||
# Option 2: Drain and shut down the background pool
|
||||
memory.close()
|
||||
```
|
||||
|
||||
|
||||
## Source and Privacy
|
||||
|
||||
Every memory record can carry a `source` tag for provenance tracking and a `private` flag for access control.
|
||||
|
||||
### Source Tracking
|
||||
|
||||
The `source` parameter identifies where a memory came from:
|
||||
|
||||
```python
|
||||
# Tag memories with their origin
|
||||
memory.remember("User prefers dark mode", source="user:alice")
|
||||
memory.remember("System config updated", source="admin")
|
||||
memory.remember("Agent found a bug", source="agent:debugger")
|
||||
|
||||
# Recall only memories from a specific source
|
||||
matches = memory.recall("user preferences", source="user:alice")
|
||||
```
|
||||
|
||||
### Private Memories
|
||||
|
||||
Private memories are only visible to recall when the `source` matches:
|
||||
|
||||
```python
|
||||
# Store a private memory
|
||||
memory.remember("Alice's API key is sk-...", source="user:alice", private=True)
|
||||
|
||||
# This recall sees the private memory (source matches)
|
||||
matches = memory.recall("API key", source="user:alice")
|
||||
|
||||
# This recall does NOT see it (different source)
|
||||
matches = memory.recall("API key", source="user:bob")
|
||||
|
||||
# Admin access: see all private records regardless of source
|
||||
matches = memory.recall("API key", include_private=True)
|
||||
```
|
||||
|
||||
This is particularly useful in multi-user or enterprise deployments where different users' memories should be isolated.
|
||||
|
||||
|
||||
## RecallFlow (Deep Recall)
|
||||
|
||||
`recall()` supports two depths:
|
||||
|
||||
- **`depth="shallow"`** -- Direct vector search with composite scoring. Fast (~200ms), no LLM calls.
|
||||
- **`depth="deep"` (default)** -- Runs a multi-step RecallFlow: query analysis, scope selection, parallel vector search, confidence-based routing, and optional recursive exploration when confidence is low.
|
||||
|
||||
**Smart LLM skip**: Queries shorter than `query_analysis_threshold` (default 200 characters) skip the LLM query analysis entirely, even in deep mode. Short queries like "What database do we use?" are already good search phrases -- the LLM analysis adds little value. This saves ~1-3s per recall for typical short queries. Only longer queries (e.g. full task descriptions) go through LLM distillation into targeted sub-queries.
|
||||
|
||||
```python
|
||||
# Shallow: pure vector search, no LLM
|
||||
matches = memory.recall("What did we decide?", limit=10, depth="shallow")
|
||||
|
||||
# Intelligent path for complex questions
|
||||
# Deep (default): intelligent retrieval with LLM analysis for long queries
|
||||
matches = memory.recall(
|
||||
"Summarize all architecture decisions from this quarter",
|
||||
limit=10,
|
||||
depth="auto",
|
||||
depth="deep",
|
||||
)
|
||||
```
|
||||
|
||||
@@ -406,6 +508,7 @@ memory = Memory(
|
||||
confidence_threshold_high=0.9, # Only synthesize when very confident
|
||||
confidence_threshold_low=0.4, # Explore deeper more aggressively
|
||||
exploration_budget=2, # Allow up to 2 exploration rounds
|
||||
query_analysis_threshold=200, # Skip LLM for queries shorter than this
|
||||
)
|
||||
```
|
||||
|
||||
@@ -613,6 +716,45 @@ memory = Memory(embedder=my_embedder)
|
||||
| Custom | `custom` | -- | Requires `embedding_callable`. |
|
||||
|
||||
|
||||
## LLM Configuration
|
||||
|
||||
Memory uses an LLM for save analysis (scope, categories, importance inference), consolidation decisions, and deep recall query analysis. You can configure which model to use.
|
||||
|
||||
```python
|
||||
from crewai import Memory, LLM
|
||||
|
||||
# Default: gpt-4o-mini
|
||||
memory = Memory()
|
||||
|
||||
# Use a different OpenAI model
|
||||
memory = Memory(llm="gpt-4o")
|
||||
|
||||
# Use Anthropic
|
||||
memory = Memory(llm="anthropic/claude-3-haiku-20240307")
|
||||
|
||||
# Use Ollama for fully local/private analysis
|
||||
memory = Memory(llm="ollama/llama3.2")
|
||||
|
||||
# Use Google Gemini
|
||||
memory = Memory(llm="gemini/gemini-2.0-flash")
|
||||
|
||||
# Pass a pre-configured LLM instance with custom settings
|
||||
llm = LLM(model="gpt-4o", temperature=0)
|
||||
memory = Memory(llm=llm)
|
||||
```
|
||||
|
||||
The LLM is initialized **lazily** -- it's only created when first needed. This means `Memory()` never fails at construction time, even if API keys aren't set. Errors only surface when the LLM is actually called (e.g. when saving without explicit scope/categories, or during deep recall).
|
||||
|
||||
For fully offline/private operation, use a local model for both the LLM and embedder:
|
||||
|
||||
```python
|
||||
memory = Memory(
|
||||
llm="ollama/llama3.2",
|
||||
embedder={"provider": "ollama", "config": {"model_name": "mxbai-embed-large"}},
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
## Storage Backend
|
||||
|
||||
- **Default**: LanceDB, stored under `./.crewai/memory` (or `$CREWAI_STORAGE_DIR/memory` if the env var is set, or the path you pass as `storage="path/to/dir"`).
|
||||
@@ -685,11 +827,18 @@ class MemoryMonitor(BaseEventListener):
|
||||
- When using a crew, confirm `memory=True` or `memory=Memory(...)` is set.
|
||||
|
||||
**Slow recall?**
|
||||
- Use `depth="shallow"` for routine agent context. Reserve `depth="auto"` or `"deep"` for complex queries.
|
||||
- Use `depth="shallow"` for routine agent context. Reserve `depth="deep"` for complex queries.
|
||||
- Increase `query_analysis_threshold` to skip LLM analysis for more queries.
|
||||
|
||||
**LLM analysis errors in logs?**
|
||||
- Memory still saves/recalls with safe defaults. Check API keys, rate limits, and model availability if you want full LLM analysis.
|
||||
|
||||
**Background save errors in logs?**
|
||||
- Memory saves run in a background thread. Errors are emitted as `MemorySaveFailedEvent` but don't crash the agent. Check logs for the root cause (usually LLM or embedder connection issues).
|
||||
|
||||
**Concurrent write conflicts?**
|
||||
- LanceDB operations are serialized with a shared lock and retried automatically on conflict. This handles multiple `Memory` instances pointing at the same database (e.g. agent memory + crew memory). No action needed.
|
||||
|
||||
**Browse memory from the terminal:**
|
||||
```bash
|
||||
crewai memory # Opens the TUI browser
|
||||
@@ -721,7 +870,9 @@ All configuration is passed as keyword arguments to `Memory(...)`. Every paramet
|
||||
| `consolidation_threshold` | `0.85` | Similarity above which consolidation is triggered on save. Set to `1.0` to disable. |
|
||||
| `consolidation_limit` | `5` | Max existing records to compare during consolidation. |
|
||||
| `default_importance` | `0.5` | Importance assigned when not provided and LLM analysis is skipped. |
|
||||
| `batch_dedup_threshold` | `0.98` | Cosine similarity for dropping near-duplicates within a `remember_many()` batch. |
|
||||
| `confidence_threshold_high` | `0.8` | Recall confidence above which results are returned directly. |
|
||||
| `confidence_threshold_low` | `0.5` | Recall confidence below which deeper exploration is triggered. |
|
||||
| `complex_query_threshold` | `0.7` | For complex queries, explore deeper below this confidence. |
|
||||
| `exploration_budget` | `1` | Number of LLM-driven exploration rounds during deep recall. |
|
||||
| `query_analysis_threshold` | `200` | Queries shorter than this (in characters) skip LLM analysis during deep recall. |
|
||||
|
||||
@@ -73,6 +73,8 @@ When this flow runs, it will:
|
||||
| `default_outcome` | `str` | No | Outcome to use if no feedback provided. Must be in `emit` |
|
||||
| `metadata` | `dict` | No | Additional data for enterprise integrations |
|
||||
| `provider` | `HumanFeedbackProvider` | No | Custom provider for async/non-blocking feedback. See [Async Human Feedback](#async-human-feedback-non-blocking) |
|
||||
| `learn` | `bool` | No | Enable HITL learning: distill lessons from feedback and pre-review future output. Default `False`. See [Learning from Feedback](#learning-from-feedback) |
|
||||
| `learn_limit` | `int` | No | Max past lessons to recall for pre-review. Default `5` |
|
||||
|
||||
### Basic Usage (No Routing)
|
||||
|
||||
@@ -576,6 +578,64 @@ If you're using an async web framework (FastAPI, aiohttp, Slack Bolt async mode)
|
||||
5. **Automatic persistence**: State is automatically saved when `HumanFeedbackPending` is raised and uses `SQLiteFlowPersistence` by default
|
||||
6. **Custom persistence**: Pass a custom persistence instance to `from_pending()` if needed
|
||||
|
||||
## Learning from Feedback
|
||||
|
||||
The `learn=True` parameter enables a feedback loop between human reviewers and the memory system. When enabled, the system progressively improves its outputs by learning from past human corrections.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **After feedback**: The LLM extracts generalizable lessons from the output + feedback and stores them in memory with `source="hitl"`. If the feedback is just approval (e.g. "looks good"), nothing is stored.
|
||||
2. **Before next review**: Past HITL lessons are recalled from memory and applied by the LLM to improve the output before the human sees it.
|
||||
|
||||
Over time, the human sees progressively better pre-reviewed output because each correction informs future reviews.
|
||||
|
||||
### Example
|
||||
|
||||
```python Code
|
||||
class ArticleReviewFlow(Flow):
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Review this article draft:",
|
||||
emit=["approved", "needs_revision"],
|
||||
llm="gpt-4o-mini",
|
||||
learn=True, # enable HITL learning
|
||||
)
|
||||
def generate_article(self):
|
||||
return self.crew.kickoff(inputs={"topic": "AI Safety"}).raw
|
||||
|
||||
@listen("approved")
|
||||
def publish(self):
|
||||
print(f"Publishing: {self.last_human_feedback.output}")
|
||||
|
||||
@listen("needs_revision")
|
||||
def revise(self):
|
||||
print("Revising based on feedback...")
|
||||
```
|
||||
|
||||
**First run**: The human sees the raw output and says "Always include citations for factual claims." The lesson is distilled and stored in memory.
|
||||
|
||||
**Second run**: The system recalls the citation lesson, pre-reviews the output to add citations, then shows the improved version. The human's job shifts from "fix everything" to "catch what the system missed."
|
||||
|
||||
### Configuration
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `learn` | `False` | Enable HITL learning |
|
||||
| `learn_limit` | `5` | Max past lessons to recall for pre-review |
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
- **Same LLM for everything**: The `llm` parameter on the decorator is shared by outcome collapsing, lesson distillation, and pre-review. No need to configure multiple models.
|
||||
- **Structured output**: Both distillation and pre-review use function calling with Pydantic models when the LLM supports it, falling back to text parsing otherwise.
|
||||
- **Non-blocking storage**: Lessons are stored via `remember_many()` which runs in a background thread -- the flow continues immediately.
|
||||
- **Graceful degradation**: If the LLM fails during distillation, nothing is stored. If it fails during pre-review, the raw output is shown. Neither failure blocks the flow.
|
||||
- **No scope/categories needed**: When storing lessons, only `source` is passed. The encoding pipeline infers scope, categories, and importance automatically.
|
||||
|
||||
<Note>
|
||||
`learn=True` requires the Flow to have memory available. Flows get memory automatically by default, but if you've disabled it with `_skip_auto_memory`, HITL learning will be silently skipped.
|
||||
</Note>
|
||||
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Flows Overview](/en/concepts/flows) - Learn about CrewAI Flows
|
||||
@@ -583,3 +643,4 @@ If you're using an async web framework (FastAPI, aiohttp, Slack Bolt async mode)
|
||||
- [Flow Persistence](/en/concepts/flows#persistence) - Persisting flow state
|
||||
- [Routing with @router](/en/concepts/flows#router) - More about conditional routing
|
||||
- [Human Input on Execution](/en/learn/human-input-on-execution) - Task-level human input
|
||||
- [Memory](/en/concepts/memory) - The unified memory system used by HITL learning
|
||||
|
||||
@@ -7,9 +7,9 @@ mode: "wide"
|
||||
|
||||
## 개요
|
||||
|
||||
CrewAI는 **통합 메모리 시스템**을 제공합니다 -- 단기, 장기, 엔터티, 외부 메모리 유형을 하나의 지능형 API인 단일 `Memory` 클래스로 대체합니다. 메모리는 저장 시 LLM을 사용하여 콘텐츠를 분석하고(범위, 카테고리, 중요도 추론) 의미 유사도, 최신성, 중요도를 결합한 복합 점수로 적응형 깊이 recall을 지원합니다.
|
||||
CrewAI는 **통합 메모리 시스템**을 제공합니다 -- 단기, 장기, 엔터티, 외부 메모리 유형을 하나의 지능형 API인 단일 `Memory` 클래스로 대체합니다. 메모리는 저장 시 LLM을 사용하여 콘텐츠를 분석하고(범위, 카테고리, 중요도 추론) 의미 유사도, 최신성, 중요도를 혼합한 복합 점수로 적응형 깊이 recall을 지원합니다.
|
||||
|
||||
메모리를 네 가지 방법으로 사용할 수 있습니다: **독립 실행** (스크립트, 노트북), **Crew와 함께**, **에이전트와 함께**, 또는 **Flow 내부에서**.
|
||||
메모리를 네 가지 방법으로 사용할 수 있습니다: **독립 실행**(스크립트, 노트북), **Crew와 함께**, **에이전트와 함께**, 또는 **Flow 내부에서**.
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
@@ -21,7 +21,7 @@ memory = Memory()
|
||||
# 저장 -- LLM이 scope, categories, importance를 추론
|
||||
memory.remember("We decided to use PostgreSQL for the user database.")
|
||||
|
||||
# 검색 -- 복합 점수(의미 + 최신성 + 중요도)로 결과 순위
|
||||
# 검색 -- 복합 점수(의미 + 최신성 + 중요도)로 결과 순위 매기기
|
||||
matches = memory.recall("What database did we choose?")
|
||||
for m in matches:
|
||||
print(f"[{m.score:.2f}] {m.record.content}")
|
||||
@@ -135,7 +135,7 @@ writer = Agent(
|
||||
|
||||
### Flow와 함께 사용
|
||||
|
||||
모든 Flow에는 내장 메모리가 있습니다. 모든 flow 메서드 내부에서 `self.remember()`, `self.recall()`, `self.extract_memories()`를 사용합니다.
|
||||
모든 Flow에는 내장 메모리가 있습니다. 모든 flow 메서드 내부에서 `self.remember()`, `self.recall()`, `self.extract_memories()`를 사용하세요.
|
||||
|
||||
```python
|
||||
from crewai.flow.flow import Flow, listen, start
|
||||
@@ -149,7 +149,7 @@ class ResearchFlow(Flow):
|
||||
|
||||
@listen(gather_data)
|
||||
def write_report(self, findings):
|
||||
# 컨텍스트를 위해 과거 연구 recall
|
||||
# 컨텍스트를 제공하기 위해 과거 연구 recall
|
||||
past = self.recall("database performance benchmarks")
|
||||
context = "\n".join(f"- {m.record.content}" for m in past)
|
||||
return f"Report:\nNew findings: {findings}\nPrevious context:\n{context}"
|
||||
@@ -377,25 +377,127 @@ memory = Memory(
|
||||
2. **recall 시** -- deep/auto recall의 경우 LLM이 쿼리(키워드, 시간 힌트, 제안 scope, 복잡도)를 분석하여 검색을 안내합니다.
|
||||
3. **메모리 추출** -- `extract_memories(content)`는 원시 텍스트(예: 작업 출력)를 개별 메모리 문장으로 나눕니다. 에이전트는 각 문장에 `remember()`를 호출하기 전에 이를 사용하여 하나의 큰 블록 대신 원자적 사실이 저장되도록 합니다.
|
||||
|
||||
모든 분석은 LLM 장애 시 우아하게 저하됩니다 -- [오류 시 동작](#오류-시-동작)을 참조하세요.
|
||||
모든 분석은 LLM 실패 시 우아하게 저하됩니다 -- [오류 시 동작](#오류-시-동작)을 참조하세요.
|
||||
|
||||
|
||||
## 메모리 통합
|
||||
|
||||
새 콘텐츠를 저장할 때 인코딩 파이프라인은 자동으로 스토리지에서 유사한 기존 레코드를 확인합니다. 유사도가 `consolidation_threshold`(기본값 0.85) 이상이면 LLM이 처리 방법을 결정합니다:
|
||||
|
||||
- **keep** -- 기존 레코드가 여전히 정확하고 중복이 아닙니다.
|
||||
- **update** -- 기존 레코드를 새 정보로 업데이트해야 합니다 (LLM이 병합된 콘텐츠를 제공).
|
||||
- **delete** -- 기존 레코드가 오래되었거나, 대체되었거나, 모순됩니다.
|
||||
- **insert_new** -- 새 콘텐츠를 별도의 레코드로 삽입해야 하는지 여부.
|
||||
|
||||
이를 통해 중복이 축적되는 것을 방지합니다. 예를 들어, "CrewAI ensures reliable operation"을 세 번 저장하면 통합이 중복을 인식하고 하나의 레코드만 유지합니다.
|
||||
|
||||
### 배치 내 중복 제거
|
||||
|
||||
`remember_many()`를 사용할 때 동일 배치 내의 항목은 스토리지에 도달하기 전에 서로 비교됩니다. 두 항목의 코사인 유사도가 `batch_dedup_threshold`(기본값 0.98) 이상이면 나중 항목이 자동으로 삭제됩니다. 이는 LLM 호출 없이 순수 벡터 연산으로 단일 배치 내의 정확하거나 거의 정확한 중복을 잡아냅니다.
|
||||
|
||||
```python
|
||||
# 2개의 레코드만 저장됨 (세 번째는 첫 번째의 거의 중복)
|
||||
memory.remember_many([
|
||||
"CrewAI supports complex workflows.",
|
||||
"Python is a great language.",
|
||||
"CrewAI supports complex workflows.", # 배치 내 중복 제거로 삭제
|
||||
])
|
||||
```
|
||||
|
||||
|
||||
## 비차단 저장
|
||||
|
||||
`remember_many()`는 **비차단**입니다 -- 인코딩 파이프라인을 백그라운드 스레드에 제출하고 즉시 반환합니다. 이는 메모리가 저장되는 동안 에이전트가 다음 작업을 계속할 수 있음을 의미합니다.
|
||||
|
||||
```python
|
||||
# 즉시 반환 -- 저장은 백그라운드에서 발생
|
||||
memory.remember_many(["Fact A.", "Fact B.", "Fact C."])
|
||||
|
||||
# recall()은 검색 전에 보류 중인 저장을 자동으로 대기
|
||||
matches = memory.recall("facts") # 3개 레코드 모두 확인 가능
|
||||
```
|
||||
|
||||
### 읽기 배리어
|
||||
|
||||
모든 `recall()` 호출은 검색 전에 자동으로 `drain_writes()`를 호출하여 쿼리가 항상 최신 저장된 레코드를 볼 수 있도록 합니다. 이는 투명하게 작동하므로 별도로 신경 쓸 필요가 없습니다.
|
||||
|
||||
### Crew 종료
|
||||
|
||||
crew가 완료되면 `kickoff()`는 `finally` 블록에서 보류 중인 모든 메모리 저장을 드레인하므로, 백그라운드 저장이 진행 중인 상태에서 crew가 완료되더라도 저장이 손실되지 않습니다.
|
||||
|
||||
### 독립 실행 사용
|
||||
|
||||
crew 수명 주기가 없는 스크립트나 노트북에서는 `drain_writes()` 또는 `close()`를 명시적으로 호출하세요:
|
||||
|
||||
```python
|
||||
memory = Memory()
|
||||
memory.remember_many(["Fact A.", "Fact B."])
|
||||
|
||||
# 옵션 1: 보류 중인 저장 대기
|
||||
memory.drain_writes()
|
||||
|
||||
# 옵션 2: 드레인 후 백그라운드 풀 종료
|
||||
memory.close()
|
||||
```
|
||||
|
||||
|
||||
## 출처 및 개인정보
|
||||
|
||||
모든 메모리 레코드는 출처 추적을 위한 `source` 태그와 접근 제어를 위한 `private` 플래그를 가질 수 있습니다.
|
||||
|
||||
### 출처 추적
|
||||
|
||||
`source` 매개변수는 메모리의 출처를 식별합니다:
|
||||
|
||||
```python
|
||||
# 메모리에 출처 태그 지정
|
||||
memory.remember("User prefers dark mode", source="user:alice")
|
||||
memory.remember("System config updated", source="admin")
|
||||
memory.remember("Agent found a bug", source="agent:debugger")
|
||||
|
||||
# 특정 출처의 메모리만 recall
|
||||
matches = memory.recall("user preferences", source="user:alice")
|
||||
```
|
||||
|
||||
### 비공개 메모리
|
||||
|
||||
비공개 메모리는 `source`가 일치할 때만 recall에서 볼 수 있습니다:
|
||||
|
||||
```python
|
||||
# 비공개 메모리 저장
|
||||
memory.remember("Alice's API key is sk-...", source="user:alice", private=True)
|
||||
|
||||
# 이 recall은 비공개 메모리를 볼 수 있음 (source 일치)
|
||||
matches = memory.recall("API key", source="user:alice")
|
||||
|
||||
# 이 recall은 볼 수 없음 (다른 source)
|
||||
matches = memory.recall("API key", source="user:bob")
|
||||
|
||||
# 관리자 액세스: source에 관계없이 모든 비공개 레코드 보기
|
||||
matches = memory.recall("API key", include_private=True)
|
||||
```
|
||||
|
||||
이는 서로 다른 사용자의 메모리가 격리되어야 하는 다중 사용자 또는 엔터프라이즈 배포에서 특히 유용합니다.
|
||||
|
||||
|
||||
## RecallFlow (딥 Recall)
|
||||
|
||||
`recall()`은 세 가지 깊이를 지원합니다:
|
||||
`recall()`은 두 가지 깊이를 지원합니다:
|
||||
|
||||
- **`depth="shallow"`** -- 복합 점수를 사용한 직접 벡터 검색. 빠름; 에이전트가 컨텍스트를 로드할 때 기본 사용.
|
||||
- **`depth="deep"` 또는 `depth="auto"`** -- 다단계 RecallFlow 실행: 쿼리 분석, scope 선택, 벡터 검색, 신뢰도 기반 라우팅, 신뢰도가 낮을 때 선택적 재귀 탐색.
|
||||
- **`depth="shallow"`** -- 복합 점수를 사용한 직접 벡터 검색. 빠름 (~200ms), LLM 호출 없음.
|
||||
- **`depth="deep"` (기본값)** -- 다단계 RecallFlow 실행: 쿼리 분석, scope 선택, 병렬 벡터 검색, 신뢰도 기반 라우팅, 신뢰도가 낮을 때 선택적 재귀 탐색.
|
||||
|
||||
**스마트 LLM 건너뛰기**: `query_analysis_threshold`(기본값 200자)보다 짧은 쿼리는 deep 모드에서도 LLM 쿼리 분석을 완전히 건너뜁니다. "What database do we use?"와 같은 짧은 쿼리는 이미 좋은 검색 구문이므로 LLM 분석이 큰 가치를 더하지 않습니다. 이를 통해 일반적인 짧은 쿼리에서 recall당 ~1-3초를 절약합니다. 긴 쿼리(예: 전체 작업 설명)만 대상 하위 쿼리로의 LLM 분석을 거칩니다.
|
||||
|
||||
```python
|
||||
# 빠른 경로 (에이전트 작업 컨텍스트 기본값)
|
||||
# Shallow: 순수 벡터 검색, LLM 없음
|
||||
matches = memory.recall("What did we decide?", limit=10, depth="shallow")
|
||||
|
||||
# 복잡한 질문용 지능형 경로
|
||||
# Deep (기본값): 긴 쿼리에 대한 LLM 분석을 포함한 지능형 검색
|
||||
matches = memory.recall(
|
||||
"Summarize all architecture decisions from this quarter",
|
||||
limit=10,
|
||||
depth="auto",
|
||||
depth="deep",
|
||||
)
|
||||
```
|
||||
|
||||
@@ -406,6 +508,7 @@ memory = Memory(
|
||||
confidence_threshold_high=0.9, # 매우 확신할 때만 합성
|
||||
confidence_threshold_low=0.4, # 더 적극적으로 깊이 탐색
|
||||
exploration_budget=2, # 최대 2라운드 탐색 허용
|
||||
query_analysis_threshold=200, # 이보다 짧은 쿼리는 LLM 건너뛰기
|
||||
)
|
||||
```
|
||||
|
||||
@@ -613,10 +716,49 @@ memory = Memory(embedder=my_embedder)
|
||||
| Custom | `custom` | -- | `embedding_callable` 필요. |
|
||||
|
||||
|
||||
## LLM 설정
|
||||
|
||||
메모리는 저장 분석(scope, categories, importance 추론), 통합 결정, 딥 recall 쿼리 분석에 LLM을 사용합니다. 사용할 모델을 설정할 수 있습니다.
|
||||
|
||||
```python
|
||||
from crewai import Memory, LLM
|
||||
|
||||
# 기본값: gpt-4o-mini
|
||||
memory = Memory()
|
||||
|
||||
# 다른 OpenAI 모델 사용
|
||||
memory = Memory(llm="gpt-4o")
|
||||
|
||||
# Anthropic 사용
|
||||
memory = Memory(llm="anthropic/claude-3-haiku-20240307")
|
||||
|
||||
# 완전한 로컬/비공개 분석을 위해 Ollama 사용
|
||||
memory = Memory(llm="ollama/llama3.2")
|
||||
|
||||
# Google Gemini 사용
|
||||
memory = Memory(llm="gemini/gemini-2.0-flash")
|
||||
|
||||
# 사용자 정의 설정이 있는 사전 구성된 LLM 인스턴스 전달
|
||||
llm = LLM(model="gpt-4o", temperature=0)
|
||||
memory = Memory(llm=llm)
|
||||
```
|
||||
|
||||
LLM은 **지연 초기화**됩니다 -- 처음 필요할 때만 생성됩니다. 즉, API 키가 설정되지 않아도 `Memory()` 생성 시에는 실패하지 않습니다. 오류는 LLM이 실제로 호출될 때만 발생합니다(예: 명시적 scope/categories 없이 저장할 때 또는 딥 recall 중).
|
||||
|
||||
완전한 오프라인/비공개 운영을 위해 LLM과 embedder 모두에 로컬 모델을 사용하세요:
|
||||
|
||||
```python
|
||||
memory = Memory(
|
||||
llm="ollama/llama3.2",
|
||||
embedder={"provider": "ollama", "config": {"model_name": "mxbai-embed-large"}},
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
## 스토리지 백엔드
|
||||
|
||||
- **기본값**: LanceDB, `./.crewai/memory` 아래에 저장 (또는 환경 변수 `$CREWAI_STORAGE_DIR/memory`가 설정된 경우, 또는 `storage="path/to/dir"`로 전달한 경로).
|
||||
- **사용자 정의 백엔드**: `StorageBackend` 프로토콜 구현 (`crewai.memory.storage.backend` 참조) 후 `Memory(storage=your_backend)`에 인스턴스 전달.
|
||||
- **기본값**: LanceDB, `./.crewai/memory` 아래에 저장 (또는 환경 변수가 설정된 경우 `$CREWAI_STORAGE_DIR/memory`, 또는 `storage="path/to/dir"`로 전달한 경로).
|
||||
- **사용자 정의 백엔드**: `StorageBackend` 프로토콜을 구현하고(`crewai.memory.storage.backend` 참조) `Memory(storage=your_backend)`에 인스턴스를 전달합니다.
|
||||
|
||||
|
||||
## 탐색(Discovery)
|
||||
@@ -685,11 +827,18 @@ class MemoryMonitor(BaseEventListener):
|
||||
- crew 사용 시 `memory=True` 또는 `memory=Memory(...)`가 설정되었는지 확인하세요.
|
||||
|
||||
**recall이 느린가요?**
|
||||
- 일상적인 에이전트 컨텍스트에는 `depth="shallow"`를 사용하세요. 복잡한 쿼리에만 `depth="auto"` 또는 `"deep"`을 사용하세요.
|
||||
- 일상적인 에이전트 컨텍스트에는 `depth="shallow"`를 사용하세요. 복잡한 쿼리에만 `depth="deep"`을 사용하세요.
|
||||
- 더 많은 쿼리에서 LLM 분석을 건너뛰려면 `query_analysis_threshold`를 높이세요.
|
||||
|
||||
**로그에 LLM 분석 오류가 있나요?**
|
||||
- 메모리는 안전한 기본값으로 계속 저장/recall합니다. 전체 LLM 분석을 원하면 API 키, 속도 제한, 모델 가용성을 확인하세요.
|
||||
|
||||
**로그에 백그라운드 저장 오류가 있나요?**
|
||||
- 메모리 저장은 백그라운드 스레드에서 실행됩니다. 오류는 `MemorySaveFailedEvent`로 발생하지만 에이전트를 중단시키지 않습니다. 근본 원인(보통 LLM 또는 embedder 연결 문제)은 로그를 확인하세요.
|
||||
|
||||
**동시 쓰기 충돌이 있나요?**
|
||||
- LanceDB 연산은 공유 잠금으로 직렬화되며 충돌 시 자동으로 재시도됩니다. 이는 동일 데이터베이스를 가리키는 여러 `Memory` 인스턴스(예: 에이전트 메모리 + crew 메모리)를 처리합니다. 별도의 조치가 필요하지 않습니다.
|
||||
|
||||
**터미널에서 메모리 탐색:**
|
||||
```bash
|
||||
crewai memory # TUI 브라우저 열기
|
||||
@@ -721,7 +870,9 @@ memory.reset(scope="/project/old") # 해당 하위 트리만
|
||||
| `consolidation_threshold` | `0.85` | 저장 시 통합이 트리거되는 유사도. `1.0`으로 설정하면 비활성화. |
|
||||
| `consolidation_limit` | `5` | 통합 중 비교할 기존 레코드 최대 수. |
|
||||
| `default_importance` | `0.5` | 미제공 시 및 LLM 분석이 생략될 때 할당되는 중요도. |
|
||||
| `batch_dedup_threshold` | `0.98` | `remember_many()` 배치 내 거의 중복 삭제를 위한 코사인 유사도. |
|
||||
| `confidence_threshold_high` | `0.8` | recall 신뢰도가 이 값 이상이면 결과를 직접 반환. |
|
||||
| `confidence_threshold_low` | `0.5` | recall 신뢰도가 이 값 미만이면 더 깊은 탐색 트리거. |
|
||||
| `complex_query_threshold` | `0.7` | 복잡한 쿼리의 경우 이 신뢰도 미만에서 더 깊이 탐색. |
|
||||
| `exploration_budget` | `1` | 딥 recall 중 LLM 기반 탐색 라운드 수. |
|
||||
| `query_analysis_threshold` | `200` | 이 길이(문자 수)보다 짧은 쿼리는 딥 recall 중 LLM 분석을 건너뜀. |
|
||||
|
||||
@@ -73,6 +73,8 @@ flow.kickoff()
|
||||
| `default_outcome` | `str` | 아니오 | 피드백이 제공되지 않을 때 사용할 outcome. `emit`에 있어야 합니다 |
|
||||
| `metadata` | `dict` | 아니오 | 엔터프라이즈 통합을 위한 추가 데이터 |
|
||||
| `provider` | `HumanFeedbackProvider` | 아니오 | 비동기/논블로킹 피드백을 위한 커스텀 프로바이더. [비동기 인간 피드백](#비동기-인간-피드백-논블로킹) 참조 |
|
||||
| `learn` | `bool` | 아니오 | HITL 학습 활성화: 피드백에서 교훈을 추출하고 향후 출력을 사전 검토합니다. 기본값 `False`. [피드백에서 학습하기](#피드백에서-학습하기) 참조 |
|
||||
| `learn_limit` | `int` | 아니오 | 사전 검토를 위해 불러올 최대 과거 교훈 수. 기본값 `5` |
|
||||
|
||||
### 기본 사용법 (라우팅 없음)
|
||||
|
||||
@@ -576,6 +578,64 @@ async def on_slack_feedback_async(flow_id: str, slack_message: str):
|
||||
5. **자동 영속성**: `HumanFeedbackPending`이 발생하면 상태가 자동으로 저장되며 기본적으로 `SQLiteFlowPersistence` 사용
|
||||
6. **커스텀 영속성**: 필요한 경우 `from_pending()`에 커스텀 영속성 인스턴스 전달
|
||||
|
||||
## 피드백에서 학습하기
|
||||
|
||||
`learn=True` 매개변수는 인간 검토자와 메모리 시스템 간의 피드백 루프를 활성화합니다. 활성화되면 시스템은 과거 인간의 수정 사항에서 학습하여 출력을 점진적으로 개선합니다.
|
||||
|
||||
### 작동 방식
|
||||
|
||||
1. **피드백 후**: LLM이 출력 + 피드백에서 일반화 가능한 교훈을 추출하고 `source="hitl"`로 메모리에 저장합니다. 피드백이 단순한 승인(예: "좋아 보입니다")인 경우 아무것도 저장하지 않습니다.
|
||||
2. **다음 검토 전**: 과거 HITL 교훈을 메모리에서 불러와 LLM이 인간이 보기 전에 출력을 개선하는 데 적용합니다.
|
||||
|
||||
시간이 지남에 따라 각 수정 사항이 향후 검토에 반영되므로 인간은 점진적으로 더 나은 사전 검토된 출력을 보게 됩니다.
|
||||
|
||||
### 예제
|
||||
|
||||
```python Code
|
||||
class ArticleReviewFlow(Flow):
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Review this article draft:",
|
||||
emit=["approved", "needs_revision"],
|
||||
llm="gpt-4o-mini",
|
||||
learn=True, # HITL 학습 활성화
|
||||
)
|
||||
def generate_article(self):
|
||||
return self.crew.kickoff(inputs={"topic": "AI Safety"}).raw
|
||||
|
||||
@listen("approved")
|
||||
def publish(self):
|
||||
print(f"Publishing: {self.last_human_feedback.output}")
|
||||
|
||||
@listen("needs_revision")
|
||||
def revise(self):
|
||||
print("Revising based on feedback...")
|
||||
```
|
||||
|
||||
**첫 번째 실행**: 인간이 원시 출력을 보고 "사실에 대한 주장에는 항상 인용을 포함하세요."라고 말합니다. 교훈이 추출되어 메모리에 저장됩니다.
|
||||
|
||||
**두 번째 실행**: 시스템이 인용 교훈을 불러와 출력을 사전 검토하여 인용을 추가한 후 개선된 버전을 표시합니다. 인간의 역할이 "모든 것을 수정"에서 "시스템이 놓친 것을 찾기"로 전환됩니다.
|
||||
|
||||
### 구성
|
||||
|
||||
| 매개변수 | 기본값 | 설명 |
|
||||
|-----------|--------|------|
|
||||
| `learn` | `False` | HITL 학습 활성화 |
|
||||
| `learn_limit` | `5` | 사전 검토를 위해 불러올 최대 과거 교훈 수 |
|
||||
|
||||
### 주요 설계 결정
|
||||
|
||||
- **모든 것에 동일한 LLM 사용**: 데코레이터의 `llm` 매개변수는 outcome 매핑, 교훈 추출, 사전 검토에 공유됩니다. 여러 모델을 구성할 필요가 없습니다.
|
||||
- **구조화된 출력**: 추출과 사전 검토 모두 LLM이 지원하는 경우 Pydantic 모델과 함께 function calling을 사용하고, 그렇지 않으면 텍스트 파싱으로 폴백합니다.
|
||||
- **논블로킹 저장**: 교훈은 백그라운드 스레드에서 실행되는 `remember_many()`를 통해 저장됩니다 -- Flow는 즉시 계속됩니다.
|
||||
- **우아한 저하**: 추출 중 LLM이 실패하면 아무것도 저장하지 않습니다. 사전 검토 중 실패하면 원시 출력이 표시됩니다. 어느 쪽의 실패도 Flow를 차단하지 않습니다.
|
||||
- **범위/카테고리 불필요**: 교훈을 저장할 때 `source`만 전달됩니다. 인코딩 파이프라인이 범위, 카테고리, 중요도를 자동으로 추론합니다.
|
||||
|
||||
<Note>
|
||||
`learn=True`는 Flow에 메모리가 사용 가능해야 합니다. Flow는 기본적으로 자동으로 메모리를 얻지만, `_skip_auto_memory`로 비활성화한 경우 HITL 학습은 조용히 건너뜁니다.
|
||||
</Note>
|
||||
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [Flow 개요](/ko/concepts/flows) - CrewAI Flow에 대해 알아보기
|
||||
@@ -583,3 +643,4 @@ async def on_slack_feedback_async(flow_id: str, slack_message: str):
|
||||
- [Flow 영속성](/ko/concepts/flows#persistence) - Flow 상태 영속화
|
||||
- [@router를 사용한 라우팅](/ko/concepts/flows#router) - 조건부 라우팅에 대해 더 알아보기
|
||||
- [실행 시 인간 입력](/ko/learn/human-input-on-execution) - 태스크 수준 인간 입력
|
||||
- [메모리](/ko/concepts/memory) - HITL 학습에서 사용되는 통합 메모리 시스템
|
||||
|
||||
@@ -380,22 +380,124 @@ A memória usa o LLM de três formas:
|
||||
Toda análise degrada graciosamente em caso de falha do LLM -- veja [Comportamento em Caso de Falha](#comportamento-em-caso-de-falha).
|
||||
|
||||
|
||||
## RecallFlow (Recall Profundo)
|
||||
## Consolidação de Memória
|
||||
|
||||
`recall()` suporta três profundidades:
|
||||
Ao salvar novo conteúdo, o pipeline de codificação verifica automaticamente registros similares existentes no armazenamento. Se a similaridade estiver acima de `consolidation_threshold` (padrão 0.85), o LLM decide o que fazer:
|
||||
|
||||
- **`depth="shallow"`** -- Busca vetorial direta com pontuação composta. Rápido; usado por padrão quando agentes carregam contexto.
|
||||
- **`depth="deep"` ou `depth="auto"`** -- Executa um RecallFlow em múltiplas etapas: análise da consulta, seleção de escopo, busca vetorial, roteamento baseado em confiança e exploração recursiva opcional quando a confiança é baixa.
|
||||
- **keep** -- O registro existente ainda é preciso e não é redundante.
|
||||
- **update** -- O registro existente deve ser atualizado com novas informações (o LLM fornece o conteúdo mesclado).
|
||||
- **delete** -- O registro existente está desatualizado, substituído ou contradito.
|
||||
- **insert_new** -- Se o novo conteúdo também deve ser inserido como um registro separado.
|
||||
|
||||
Isso evita o acúmulo de duplicatas. Por exemplo, se você salvar "CrewAI garante operação confiável" três vezes, a consolidação reconhece as duplicatas e mantém apenas um registro.
|
||||
|
||||
### Dedup Intra-batch
|
||||
|
||||
Ao usar `remember_many()`, os itens dentro do mesmo batch são comparados entre si antes de atingir o armazenamento. Se dois itens tiverem similaridade de cosseno >= `batch_dedup_threshold` (padrão 0.98), o posterior é silenciosamente descartado. Isso captura duplicatas exatas ou quase exatas dentro de um único batch sem chamadas ao LLM (pura matemática vetorial).
|
||||
|
||||
```python
|
||||
# Caminho rápido (padrão para contexto de tarefa do agente)
|
||||
# Apenas 2 registros são armazenados (o terceiro é quase duplicata do primeiro)
|
||||
memory.remember_many([
|
||||
"CrewAI supports complex workflows.",
|
||||
"Python is a great language.",
|
||||
"CrewAI supports complex workflows.", # descartado pelo dedup intra-batch
|
||||
])
|
||||
```
|
||||
|
||||
|
||||
## Saves Não-Bloqueantes
|
||||
|
||||
`remember_many()` é **não-bloqueante** -- ele envia o pipeline de codificação para uma thread em background e retorna imediatamente. Isso significa que o agente pode continuar para a próxima tarefa enquanto as memórias estão sendo salvas.
|
||||
|
||||
```python
|
||||
# Retorna imediatamente -- save acontece em background
|
||||
memory.remember_many(["Fato A.", "Fato B.", "Fato C."])
|
||||
|
||||
# recall() espera automaticamente saves pendentes antes de buscar
|
||||
matches = memory.recall("fatos") # vê todos os 3 registros
|
||||
```
|
||||
|
||||
### Barreira de Leitura
|
||||
|
||||
Cada chamada `recall()` executa automaticamente `drain_writes()` antes de buscar, garantindo que a consulta sempre veja os registros mais recentes persistidos. Isso é transparente -- você nunca precisa pensar nisso.
|
||||
|
||||
### Encerramento da Crew
|
||||
|
||||
Quando uma crew termina, `kickoff()` drena todos os saves de memória pendentes em seu bloco `finally`, então nenhum save é perdido mesmo que a crew complete enquanto saves em background estão em andamento.
|
||||
|
||||
### Uso Standalone
|
||||
|
||||
Para scripts ou notebooks onde não há ciclo de vida de crew, chame `drain_writes()` ou `close()` explicitamente:
|
||||
|
||||
```python
|
||||
memory = Memory()
|
||||
memory.remember_many(["Fato A.", "Fato B."])
|
||||
|
||||
# Opção 1: Esperar saves pendentes
|
||||
memory.drain_writes()
|
||||
|
||||
# Opção 2: Drenar e encerrar o pool de background
|
||||
memory.close()
|
||||
```
|
||||
|
||||
|
||||
## Origem e Privacidade
|
||||
|
||||
Cada registro de memória pode carregar uma tag `source` para rastreamento de procedência e uma flag `private` para controle de acesso.
|
||||
|
||||
### Rastreamento de Origem
|
||||
|
||||
O parâmetro `source` identifica de onde uma memória veio:
|
||||
|
||||
```python
|
||||
# Marcar memórias com sua origem
|
||||
memory.remember("Usuário prefere modo escuro", source="user:alice")
|
||||
memory.remember("Configuração do sistema atualizada", source="admin")
|
||||
memory.remember("Agente encontrou um bug", source="agent:debugger")
|
||||
|
||||
# Recuperar apenas memórias de uma origem específica
|
||||
matches = memory.recall("preferências do usuário", source="user:alice")
|
||||
```
|
||||
|
||||
### Memórias Privadas
|
||||
|
||||
Memórias privadas só são visíveis no recall quando o `source` corresponde:
|
||||
|
||||
```python
|
||||
# Armazenar uma memória privada
|
||||
memory.remember("A chave de API da Alice é sk-...", source="user:alice", private=True)
|
||||
|
||||
# Este recall vê a memória privada (source corresponde)
|
||||
matches = memory.recall("chave de API", source="user:alice")
|
||||
|
||||
# Este recall NÃO a vê (source diferente)
|
||||
matches = memory.recall("chave de API", source="user:bob")
|
||||
|
||||
# Acesso admin: ver todos os registros privados independente do source
|
||||
matches = memory.recall("chave de API", include_private=True)
|
||||
```
|
||||
|
||||
Isso é particularmente útil em implantações multi-usuário ou corporativas onde memórias de diferentes usuários devem ser isoladas.
|
||||
|
||||
|
||||
## RecallFlow (Recall Profundo)
|
||||
|
||||
`recall()` suporta duas profundidades:
|
||||
|
||||
- **`depth="shallow"`** -- Busca vetorial direta com pontuação composta. Rápido (~200ms), sem chamadas ao LLM.
|
||||
- **`depth="deep"` (padrão)** -- Executa um RecallFlow em múltiplas etapas: análise da consulta, seleção de escopo, busca vetorial paralela, roteamento baseado em confiança e exploração recursiva opcional quando a confiança é baixa.
|
||||
|
||||
**Pulo inteligente do LLM**: Consultas com menos de `query_analysis_threshold` (padrão 200 caracteres) pulam a análise de consulta do LLM inteiramente, mesmo no modo deep. Consultas curtas como "Qual banco de dados usamos?" já são boas frases de busca -- a análise do LLM agrega pouco valor. Isso economiza ~1-3s por recall para consultas curtas típicas. Apenas consultas mais longas (ex.: descrições completas de tarefas) passam pela destilação do LLM em sub-consultas direcionadas.
|
||||
|
||||
```python
|
||||
# Shallow: busca vetorial pura, sem LLM
|
||||
matches = memory.recall("O que decidimos?", limit=10, depth="shallow")
|
||||
|
||||
# Caminho inteligente para perguntas complexas
|
||||
# Deep (padrão): recuperação inteligente com análise LLM para consultas longas
|
||||
matches = memory.recall(
|
||||
"Resuma todas as decisões de arquitetura deste trimestre",
|
||||
limit=10,
|
||||
depth="auto",
|
||||
depth="deep",
|
||||
)
|
||||
```
|
||||
|
||||
@@ -406,6 +508,7 @@ memory = Memory(
|
||||
confidence_threshold_high=0.9, # Só sintetizar quando muito confiante
|
||||
confidence_threshold_low=0.4, # Explorar mais profundamente de forma mais agressiva
|
||||
exploration_budget=2, # Permitir até 2 rodadas de exploração
|
||||
query_analysis_threshold=200, # Pular LLM para consultas menores que isso
|
||||
)
|
||||
```
|
||||
|
||||
@@ -613,6 +716,45 @@ memory = Memory(embedder=my_embedder)
|
||||
| Custom | `custom` | -- | Requer `embedding_callable`. |
|
||||
|
||||
|
||||
## Configuração de LLM
|
||||
|
||||
A memória usa um LLM para análise de save (inferência de escopo, categorias e importância), decisões de consolidação e análise de consulta no recall profundo. Você pode configurar qual modelo usar.
|
||||
|
||||
```python
|
||||
from crewai import Memory, LLM
|
||||
|
||||
# Padrão: gpt-4o-mini
|
||||
memory = Memory()
|
||||
|
||||
# Usar um modelo OpenAI diferente
|
||||
memory = Memory(llm="gpt-4o")
|
||||
|
||||
# Usar Anthropic
|
||||
memory = Memory(llm="anthropic/claude-3-haiku-20240307")
|
||||
|
||||
# Usar Ollama para análise totalmente local/privada
|
||||
memory = Memory(llm="ollama/llama3.2")
|
||||
|
||||
# Usar Google Gemini
|
||||
memory = Memory(llm="gemini/gemini-2.0-flash")
|
||||
|
||||
# Passar uma instância LLM pré-configurada com configurações customizadas
|
||||
llm = LLM(model="gpt-4o", temperature=0)
|
||||
memory = Memory(llm=llm)
|
||||
```
|
||||
|
||||
O LLM é inicializado **lazily** -- ele só é criado quando necessário pela primeira vez. Isso significa que `Memory()` nunca falha no momento da construção, mesmo que chaves de API não estejam definidas. Erros só aparecem quando o LLM é realmente chamado (ex.: ao salvar sem escopo/categorias explícitos, ou durante recall profundo).
|
||||
|
||||
Para operação totalmente offline/privada, use um modelo local tanto para o LLM quanto para o embedder:
|
||||
|
||||
```python
|
||||
memory = Memory(
|
||||
llm="ollama/llama3.2",
|
||||
embedder={"provider": "ollama", "config": {"model_name": "mxbai-embed-large"}},
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
## Backend de Armazenamento
|
||||
|
||||
- **Padrão**: LanceDB, armazenado em `./.crewai/memory` (ou `$CREWAI_STORAGE_DIR/memory` se a variável de ambiente estiver definida, ou o caminho que você passar como `storage="path/to/dir"`).
|
||||
@@ -685,11 +827,18 @@ class MemoryMonitor(BaseEventListener):
|
||||
- Ao usar uma crew, confirme que `memory=True` ou `memory=Memory(...)` está definido.
|
||||
|
||||
**Recall lento?**
|
||||
- Use `depth="shallow"` para contexto rotineiro do agente. Reserve `depth="auto"` ou `"deep"` para consultas complexas.
|
||||
- Use `depth="shallow"` para contexto rotineiro do agente. Reserve `depth="deep"` para consultas complexas.
|
||||
- Aumente `query_analysis_threshold` para pular a análise do LLM em mais consultas.
|
||||
|
||||
**Erros de análise LLM nos logs?**
|
||||
- A memória ainda salva/recupera com padrões seguros. Verifique chaves de API, limites de taxa e disponibilidade do modelo se quiser análise LLM completa.
|
||||
|
||||
**Erros de save em background nos logs?**
|
||||
- Os saves de memória rodam em uma thread em background. Erros são emitidos como `MemorySaveFailedEvent` mas não derrubam o agente. Verifique os logs para a causa raiz (geralmente problemas de conexão com LLM ou embedder).
|
||||
|
||||
**Conflitos de escrita concorrente?**
|
||||
- As operações do LanceDB são serializadas com um lock compartilhado e reexecutadas automaticamente em caso de conflito. Isso lida com múltiplas instâncias `Memory` apontando para o mesmo banco de dados (ex.: memória do agente + memória da crew). Nenhuma ação necessária.
|
||||
|
||||
**Navegar na memória pelo terminal:**
|
||||
```bash
|
||||
crewai memory # Abre o navegador TUI
|
||||
@@ -721,7 +870,9 @@ Toda a configuração é passada como argumentos nomeados para `Memory(...)`. Ca
|
||||
| `consolidation_threshold` | `0.85` | Similaridade acima da qual a consolidação é ativada no save. Defina `1.0` para desativar. |
|
||||
| `consolidation_limit` | `5` | Máx. de registros existentes para comparar durante consolidação. |
|
||||
| `default_importance` | `0.5` | Importância atribuída quando não fornecida e a análise LLM é pulada. |
|
||||
| `batch_dedup_threshold` | `0.98` | Similaridade de cosseno para descartar quase-duplicatas dentro de um batch `remember_many()`. |
|
||||
| `confidence_threshold_high` | `0.8` | Confiança de recall acima da qual resultados são retornados diretamente. |
|
||||
| `confidence_threshold_low` | `0.5` | Confiança de recall abaixo da qual exploração mais profunda é ativada. |
|
||||
| `complex_query_threshold` | `0.7` | Para consultas complexas, explorar mais profundamente abaixo desta confiança. |
|
||||
| `exploration_budget` | `1` | Número de rodadas de exploração por LLM durante recall profundo. |
|
||||
| `query_analysis_threshold` | `200` | Consultas menores que isso (em caracteres) pulam análise LLM durante recall profundo. |
|
||||
|
||||
@@ -73,6 +73,8 @@ Quando este flow é executado, ele irá:
|
||||
| `default_outcome` | `str` | Não | Outcome a usar se nenhum feedback for fornecido. Deve estar em `emit` |
|
||||
| `metadata` | `dict` | Não | Dados adicionais para integrações enterprise |
|
||||
| `provider` | `HumanFeedbackProvider` | Não | Provider customizado para feedback assíncrono/não-bloqueante. Veja [Feedback Humano Assíncrono](#feedback-humano-assíncrono-não-bloqueante) |
|
||||
| `learn` | `bool` | Não | Habilitar aprendizado HITL: destila lições do feedback e pré-revisa saídas futuras. Padrão `False`. Veja [Aprendendo com Feedback](#aprendendo-com-feedback) |
|
||||
| `learn_limit` | `int` | Não | Máximo de lições passadas para recuperar na pré-revisão. Padrão `5` |
|
||||
|
||||
### Uso Básico (Sem Roteamento)
|
||||
|
||||
@@ -576,6 +578,64 @@ Se você está usando um framework web assíncrono (FastAPI, aiohttp, Slack Bolt
|
||||
5. **Persistência automática**: O estado é automaticamente salvo quando `HumanFeedbackPending` é lançado e usa `SQLiteFlowPersistence` por padrão
|
||||
6. **Persistência customizada**: Passe uma instância de persistência customizada para `from_pending()` se necessário
|
||||
|
||||
## Aprendendo com Feedback
|
||||
|
||||
O parâmetro `learn=True` habilita um ciclo de feedback entre revisores humanos e o sistema de memória. Quando habilitado, o sistema melhora progressivamente suas saídas aprendendo com correções humanas anteriores.
|
||||
|
||||
### Como Funciona
|
||||
|
||||
1. **Após o feedback**: O LLM extrai lições generalizáveis da saída + feedback e as armazena na memória com `source="hitl"`. Se o feedback for apenas aprovação (ex: "parece bom"), nada é armazenado.
|
||||
2. **Antes da próxima revisão**: Lições HITL passadas são recuperadas da memória e aplicadas pelo LLM para melhorar a saída antes que o humano a veja.
|
||||
|
||||
Com o tempo, o humano vê saídas pré-revisadas progressivamente melhores porque cada correção informa revisões futuras.
|
||||
|
||||
### Exemplo
|
||||
|
||||
```python Code
|
||||
class ArticleReviewFlow(Flow):
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Review this article draft:",
|
||||
emit=["approved", "needs_revision"],
|
||||
llm="gpt-4o-mini",
|
||||
learn=True, # enable HITL learning
|
||||
)
|
||||
def generate_article(self):
|
||||
return self.crew.kickoff(inputs={"topic": "AI Safety"}).raw
|
||||
|
||||
@listen("approved")
|
||||
def publish(self):
|
||||
print(f"Publishing: {self.last_human_feedback.output}")
|
||||
|
||||
@listen("needs_revision")
|
||||
def revise(self):
|
||||
print("Revising based on feedback...")
|
||||
```
|
||||
|
||||
**Primeira execução**: O humano vê a saída bruta e diz "Sempre inclua citações para afirmações factuais." A lição é destilada e armazenada na memória.
|
||||
|
||||
**Segunda execução**: O sistema recupera a lição sobre citações, pré-revisa a saída para adicionar citações e então mostra a versão melhorada. O trabalho do humano muda de "corrigir tudo" para "identificar o que o sistema deixou passar."
|
||||
|
||||
### Configuração
|
||||
|
||||
| Parâmetro | Padrão | Descrição |
|
||||
|-----------|--------|-----------|
|
||||
| `learn` | `False` | Habilitar aprendizado HITL |
|
||||
| `learn_limit` | `5` | Máximo de lições passadas para recuperar na pré-revisão |
|
||||
|
||||
### Decisões de Design Principais
|
||||
|
||||
- **Mesmo LLM para tudo**: O parâmetro `llm` no decorador é compartilhado pelo mapeamento de outcome, destilação de lições e pré-revisão. Não é necessário configurar múltiplos modelos.
|
||||
- **Saída estruturada**: Tanto a destilação quanto a pré-revisão usam function calling com modelos Pydantic quando o LLM suporta, com fallback para parsing de texto caso contrário.
|
||||
- **Armazenamento não-bloqueante**: Lições são armazenadas via `remember_many()` que executa em uma thread em segundo plano -- o flow continua imediatamente.
|
||||
- **Degradação graciosa**: Se o LLM falhar durante a destilação, nada é armazenado. Se falhar durante a pré-revisão, a saída bruta é mostrada. Nenhuma falha bloqueia o flow.
|
||||
- **Sem escopo/categorias necessários**: Ao armazenar lições, apenas `source` é passado. O pipeline de codificação infere escopo, categorias e importância automaticamente.
|
||||
|
||||
<Note>
|
||||
`learn=True` requer que o Flow tenha memória disponível. Flows obtêm memória automaticamente por padrão, mas se você a desabilitou com `_skip_auto_memory`, o aprendizado HITL será silenciosamente ignorado.
|
||||
</Note>
|
||||
|
||||
|
||||
## Documentação Relacionada
|
||||
|
||||
- [Visão Geral de Flows](/pt-BR/concepts/flows) - Aprenda sobre CrewAI Flows
|
||||
@@ -583,3 +643,4 @@ Se você está usando um framework web assíncrono (FastAPI, aiohttp, Slack Bolt
|
||||
- [Persistência de Flows](/pt-BR/concepts/flows#persistence) - Persistindo estado de flows
|
||||
- [Roteamento com @router](/pt-BR/concepts/flows#router) - Mais sobre roteamento condicional
|
||||
- [Input Humano na Execução](/pt-BR/learn/human-input-on-execution) - Input humano no nível de task
|
||||
- [Memória](/pt-BR/concepts/memory) - O sistema unificado de memória usado pelo aprendizado HITL
|
||||
|
||||
@@ -1974,6 +1974,9 @@ class Flow(Generic[T], metaclass=FlowMeta):
|
||||
|
||||
return final_output
|
||||
finally:
|
||||
# Ensure all background memory saves complete before returning
|
||||
if self.memory is not None and hasattr(self.memory, "drain_writes"):
|
||||
self.memory.drain_writes()
|
||||
if request_id_token is not None:
|
||||
current_flow_request_id.reset(request_id_token)
|
||||
if flow_id_token is not None:
|
||||
@@ -2530,8 +2533,12 @@ class Flow(Generic[T], metaclass=FlowMeta):
|
||||
return (None, None)
|
||||
# For cyclic flows, clear from completed to allow re-execution
|
||||
self._completed_methods.discard(listener_name)
|
||||
# Also clear from fired OR listeners for cyclic flows
|
||||
self._discard_or_listener(listener_name)
|
||||
# Clear ALL fired OR listeners so they can fire again in the new cycle.
|
||||
# This mirrors what _execute_start_method does for start-method cycles.
|
||||
# Only discarding the individual listener is insufficient because
|
||||
# downstream or_() listeners (e.g., method_a listening to
|
||||
# or_(handler_a, handler_b)) would remain suppressed across iterations.
|
||||
self._clear_or_listeners()
|
||||
|
||||
try:
|
||||
method = self._methods[listener_name]
|
||||
|
||||
@@ -419,8 +419,22 @@ class LLM(BaseLLM):
|
||||
|
||||
# FALLBACK to LiteLLM
|
||||
if not LITELLM_AVAILABLE:
|
||||
logger.error("LiteLLM is not available, falling back to LiteLLM")
|
||||
raise ImportError("Fallback to LiteLLM is not available") from None
|
||||
native_list = ", ".join(SUPPORTED_NATIVE_PROVIDERS)
|
||||
error_msg = (
|
||||
f"Unable to initialize LLM with model '{model}'. "
|
||||
f"The model did not match any supported native provider "
|
||||
f"({native_list}), and the LiteLLM fallback package is not "
|
||||
f"installed.\n\n"
|
||||
f"To fix this, either:\n"
|
||||
f" 1. Install LiteLLM for broad model support: "
|
||||
f"uv add litellm\n"
|
||||
f"or\n"
|
||||
f"pip install litellm\n\n"
|
||||
f"For more details, see: "
|
||||
f"https://docs.crewai.com/en/learn/llm-connections"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
raise ImportError(error_msg) from None
|
||||
|
||||
instance = object.__new__(cls)
|
||||
super(LLM, instance).__init__(model=model, is_litellm=True, **kwargs)
|
||||
|
||||
@@ -224,22 +224,30 @@ class Memory:
|
||||
return future
|
||||
|
||||
def _on_save_done(self, future: Future[Any]) -> None:
|
||||
"""Remove a completed future from the pending list and emit failure event if needed."""
|
||||
with self._pending_lock:
|
||||
try:
|
||||
self._pending_saves.remove(future)
|
||||
except ValueError:
|
||||
pass # already removed
|
||||
exc = future.exception()
|
||||
if exc is not None:
|
||||
crewai_event_bus.emit(
|
||||
self,
|
||||
MemorySaveFailedEvent(
|
||||
value="background save",
|
||||
error=str(exc),
|
||||
source_type="unified_memory",
|
||||
),
|
||||
)
|
||||
"""Remove a completed future from the pending list and emit failure event if needed.
|
||||
|
||||
This callback must never raise -- it runs from the thread pool's
|
||||
internal machinery during process shutdown when executors and the
|
||||
event bus may already be closed.
|
||||
"""
|
||||
try:
|
||||
with self._pending_lock:
|
||||
try:
|
||||
self._pending_saves.remove(future)
|
||||
except ValueError:
|
||||
pass # already removed
|
||||
exc = future.exception()
|
||||
if exc is not None:
|
||||
crewai_event_bus.emit(
|
||||
self,
|
||||
MemorySaveFailedEvent(
|
||||
value="background save",
|
||||
error=str(exc),
|
||||
source_type="unified_memory",
|
||||
),
|
||||
)
|
||||
except Exception: # noqa: S110
|
||||
pass # swallow everything during shutdown
|
||||
|
||||
def drain_writes(self) -> None:
|
||||
"""Block until all pending background saves have completed.
|
||||
@@ -437,30 +445,49 @@ class Memory:
|
||||
|
||||
Both started and completed events are emitted here (in the background
|
||||
thread) so they pair correctly on the event bus scope stack.
|
||||
|
||||
All ``emit`` calls are wrapped in try/except to handle the case where
|
||||
the event bus shuts down before the background save finishes (e.g.
|
||||
during process exit).
|
||||
"""
|
||||
crewai_event_bus.emit(
|
||||
self,
|
||||
MemorySaveStartedEvent(
|
||||
value=f"{len(contents)} memories (background)",
|
||||
metadata=metadata,
|
||||
source_type="unified_memory",
|
||||
),
|
||||
)
|
||||
start = time.perf_counter()
|
||||
records = self._encode_batch(
|
||||
contents, scope, categories, metadata, importance, source, private
|
||||
)
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
crewai_event_bus.emit(
|
||||
self,
|
||||
MemorySaveCompletedEvent(
|
||||
value=f"{len(records)} memories saved",
|
||||
metadata=metadata or {},
|
||||
agent_role=agent_role,
|
||||
save_time_ms=elapsed_ms,
|
||||
source_type="unified_memory",
|
||||
),
|
||||
)
|
||||
try:
|
||||
crewai_event_bus.emit(
|
||||
self,
|
||||
MemorySaveStartedEvent(
|
||||
value=f"{len(contents)} memories (background)",
|
||||
metadata=metadata,
|
||||
source_type="unified_memory",
|
||||
),
|
||||
)
|
||||
except RuntimeError:
|
||||
pass # event bus shut down during process exit
|
||||
|
||||
try:
|
||||
start = time.perf_counter()
|
||||
records = self._encode_batch(
|
||||
contents, scope, categories, metadata, importance, source, private
|
||||
)
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
except RuntimeError:
|
||||
# The encoding pipeline uses asyncio.run() -> to_thread() internally.
|
||||
# If the process is shutting down, the default executor is closed and
|
||||
# to_thread raises "cannot schedule new futures after shutdown".
|
||||
# Silently abandon the save -- the process is exiting anyway.
|
||||
return []
|
||||
|
||||
try:
|
||||
crewai_event_bus.emit(
|
||||
self,
|
||||
MemorySaveCompletedEvent(
|
||||
value=f"{len(records)} memories saved",
|
||||
metadata=metadata or {},
|
||||
agent_role=agent_role,
|
||||
save_time_ms=elapsed_ms,
|
||||
source_type="unified_memory",
|
||||
),
|
||||
)
|
||||
except RuntimeError:
|
||||
pass # event bus shut down during process exit
|
||||
return records
|
||||
|
||||
def extract_memories(self, content: str) -> list[str]:
|
||||
|
||||
@@ -1647,3 +1647,128 @@ class TestFlowAkickoff:
|
||||
|
||||
assert execution_order == ["begin", "route", "path_a"]
|
||||
assert result == "path_a_result"
|
||||
|
||||
|
||||
def test_cyclic_flow_or_listeners_fire_every_iteration():
|
||||
"""Test that or_() listeners reset between cycle iterations through a router.
|
||||
|
||||
Regression test for a bug where _fired_or_listeners was not cleared when
|
||||
cycles loop through a router/listener instead of a @start method, causing
|
||||
or_() listeners to permanently suppress after the first iteration.
|
||||
|
||||
Pattern: router classifies → routes to ONE of several handlers → or_()
|
||||
merge downstream → cycle back. Only one handler fires per iteration, but
|
||||
the or_() merge must still fire every time.
|
||||
"""
|
||||
execution_order = []
|
||||
|
||||
class CyclicOrFlow(Flow):
|
||||
iteration = 0
|
||||
max_iterations = 3
|
||||
|
||||
@start()
|
||||
def begin(self):
|
||||
execution_order.append("begin")
|
||||
|
||||
@router(or_(begin, "loop_back"))
|
||||
def route(self):
|
||||
self.iteration += 1
|
||||
execution_order.append(f"route_{self.iteration}")
|
||||
if self.iteration <= self.max_iterations:
|
||||
# Alternate between handlers on each iteration
|
||||
return "type_a" if self.iteration % 2 == 1 else "type_b"
|
||||
return "done"
|
||||
|
||||
@listen("type_a")
|
||||
def handler_a(self):
|
||||
execution_order.append(f"handler_a_{self.iteration}")
|
||||
|
||||
@listen("type_b")
|
||||
def handler_b(self):
|
||||
execution_order.append(f"handler_b_{self.iteration}")
|
||||
|
||||
# This or_() listener must fire on EVERY iteration, not just the first
|
||||
@listen(or_(handler_a, handler_b))
|
||||
def merge(self):
|
||||
execution_order.append(f"merge_{self.iteration}")
|
||||
|
||||
@listen(merge)
|
||||
def loop_back(self):
|
||||
execution_order.append(f"loop_back_{self.iteration}")
|
||||
|
||||
flow = CyclicOrFlow()
|
||||
flow.kickoff()
|
||||
|
||||
# merge must have fired once per iteration (3 times total)
|
||||
merge_events = [e for e in execution_order if e.startswith("merge_")]
|
||||
assert len(merge_events) == 3, (
|
||||
f"or_() listener 'merge' should fire every iteration, "
|
||||
f"got {len(merge_events)} fires: {execution_order}"
|
||||
)
|
||||
|
||||
# loop_back must have also fired every iteration
|
||||
loop_back_events = [e for e in execution_order if e.startswith("loop_back_")]
|
||||
assert len(loop_back_events) == 3, (
|
||||
f"'loop_back' should fire every iteration, "
|
||||
f"got {len(loop_back_events)} fires: {execution_order}"
|
||||
)
|
||||
|
||||
# Verify alternating handlers
|
||||
handler_a_events = [e for e in execution_order if e.startswith("handler_a_")]
|
||||
handler_b_events = [e for e in execution_order if e.startswith("handler_b_")]
|
||||
assert len(handler_a_events) == 2 # iterations 1 and 3
|
||||
assert len(handler_b_events) == 1 # iteration 2
|
||||
|
||||
|
||||
def test_cyclic_flow_multiple_or_listeners_fire_every_iteration():
|
||||
"""Test that multiple or_() listeners all reset between cycle iterations.
|
||||
|
||||
Mirrors a real-world pattern: a router classifies messages, handlers process
|
||||
them, then both a 'send' step (or_ on handlers) and a 'store' step (or_ on
|
||||
router outputs) must fire on every loop iteration.
|
||||
"""
|
||||
execution_order = []
|
||||
|
||||
class MultiOrCyclicFlow(Flow):
|
||||
iteration = 0
|
||||
max_iterations = 3
|
||||
|
||||
@start()
|
||||
def begin(self):
|
||||
execution_order.append("begin")
|
||||
|
||||
@router(or_(begin, "capture"))
|
||||
def classify(self):
|
||||
self.iteration += 1
|
||||
execution_order.append(f"classify_{self.iteration}")
|
||||
if self.iteration <= self.max_iterations:
|
||||
return "type_a"
|
||||
return "exit"
|
||||
|
||||
@listen("type_a")
|
||||
def handle_type_a(self):
|
||||
execution_order.append(f"handle_a_{self.iteration}")
|
||||
|
||||
# or_() listener on router output strings — must fire every iteration
|
||||
@listen(or_("type_a", "type_b", "type_c"))
|
||||
def store(self):
|
||||
execution_order.append(f"store_{self.iteration}")
|
||||
|
||||
# or_() listener on handler methods — must fire every iteration
|
||||
@listen(or_(handle_type_a,))
|
||||
def send(self):
|
||||
execution_order.append(f"send_{self.iteration}")
|
||||
|
||||
@listen("send")
|
||||
def capture(self):
|
||||
execution_order.append(f"capture_{self.iteration}")
|
||||
|
||||
flow = MultiOrCyclicFlow()
|
||||
flow.kickoff()
|
||||
|
||||
for method in ["store", "send", "capture"]:
|
||||
events = [e for e in execution_order if e.startswith(f"{method}_")]
|
||||
assert len(events) == 3, (
|
||||
f"'{method}' should fire every iteration, "
|
||||
f"got {len(events)} fires: {execution_order}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user