在实际 AI 应用开发中,一个常见的痛点是如何追踪模型生成内容的来源。无论是基于 RAG 的问答系统,还是多模型协作的 Agent 工作流,当 AI 给出一个答案或一段代码时,开发者或用户往往需要知道这个结论是基于哪些文档、哪段代码或哪个数据片段得出的。这不仅关乎结果的可信度,更是调试、优化和合规审计的关键。Korvo 作为一个“Local-first AI workspace”,其核心设计理念正是为了解决这一问题:在本地优先的环境中构建 AI 应用,并确保每一个答案都能追溯到其源头。
本文将深入探讨如何构建一个具备“答案溯源”能力的本地 AI 工作空间。我们将从核心概念入手,解释“Local-first”和“溯源”在工程实践中的具体含义,然后逐步搭建一个最小可运行的示例项目。这个项目将模拟一个简单的文档问答场景,展示如何记录 AI 推理过程中的关键节点和数据来源,最终生成一份清晰的可追溯报告。无论你是正在构建内部 AI 工具的产品经理,还是需要为 AI 功能添加审计能力的开发者,这篇文章提供的思路和代码都能为你提供一个坚实的起点。
1. 理解“Local-first”与“答案溯源”的工程内涵
在开始动手之前,必须厘清两个核心概念:“Local-first”工作空间和“答案溯源”。它们并非营销术语,而是对应着具体的技术选型和架构设计。
1.1 什么是“Local-first” AI 工作空间?
“Local-first”并非简单地指软件可以离线运行。在 AI 工作空间的语境下,它是一套设计原则,其核心在于数据主权和计算主权归属本地。这意味着:
- 核心数据本地存储:用于驱动 AI 模型的提示词模板、知识库文档、对话历史、个人配置等敏感或关键数据,其主副本存储在用户自己的设备上。云服务可能用于同步或备份,但非必需。
- 模型推理本地可选:工作空间优先支持在本地硬件(利用 CPU/GPU)上运行开源模型(如 Llama、Qwen 系列)。对于需要更大算力的任务,它可以“降级”为调用远程 API(如 OpenAI, Claude),但调用过程透明,且关键预处理和后处理逻辑仍在本地控制。
- 工作流定义本地化:AI 任务的流水线、Agent 的协作逻辑、对结果的后续处理规则,都由本地的配置文件或代码定义,不依赖于某个特定云服务的专有流程。
这种架构的优势非常明显:隐私性好、定制性强、无供应商锁定,且对网络依赖低。其挑战则在于需要开发者处理本地模型部署、资源调度和跨平台兼容性等问题。
1.2 “答案溯源”需要追踪什么?
“答案溯源”的目标是建立从 AI 输出的最终答案,反向链接到影响该答案生成的原始输入和中间步骤。一个完整的溯源链条通常需要记录以下几层信息:
| 溯源层级 | 记录内容 | 示例 | 技术实现方式 |
|---|---|---|---|
| 原始输入源 | 触发任务的初始请求或查询。 | 用户问题:“公司年假制度是怎样的?” | 记录原始query字符串、时间戳、会话 ID。 |
| 检索上下文 | 从知识库中检索到的相关文档片段。 | 检索到《员工手册.pdf》第3页第5-10行。 | 记录文档 ID、片段 ID、片段内容、相关性分数。 |
| 提示词工程 | 实际发送给模型的完整提示词(Prompt)。 | 包含系统指令、检索到的上下文、用户问题的完整 Prompt。 | 记录渲染后的 Prompt 模板和填充的变量。 |
| 模型调用 | 调用的模型名称、参数、token 使用量。 | model=gpt-4,temperature=0.1,used_tokens=150。 | 记录 API 请求/响应日志,或本地推理引擎的调用参数。 |
| 中间步骤 | Agent 工作流中的思考、工具调用、子任务结果。 | Agent 决定调用“计算器”工具,输入为2+2,输出为4。 | 在 Agent 执行框架中埋点,记录每个 Action 的输入输出。 |
| 最终输出 | AI 返回的最终答案或生成物。 | “根据《员工手册》,年假为15天。” | 记录完整的响应内容。 |
实现溯源的关键在于,在工作流执行的每一个关键节点,同步生成并持久化一份结构化的“溯源元数据”,并将这些元数据通过唯一的trace_id关联起来。
2. 环境准备与项目初始化
我们将使用 Python 作为主要开发语言,构建一个概念验证性的本地问答溯源系统。这个系统将模拟从加载本地文档、检索相关片段、调用 AI 模型到生成溯源报告的完整流程。
2.1 基础环境与依赖
首先确保你的开发环境满足以下要求:
- Python 3.9+:这是大多数现代 AI 库的基础要求。
- 包管理工具:使用
pip或poetry。 - 本地向量数据库:为了体现“Local-first”,我们选用
ChromaDB,它轻量且可嵌入。 - 本地嵌入模型:选用
sentence-transformers库中的轻量模型,避免调用远程 API。 - 可选 AI 模型:为了演示完整性,我们将同时展示本地模型(通过
Ollama)和远程 API(OpenAI)两种方式。你可以根据自身条件选择。
创建项目目录并初始化虚拟环境:
mkdir korvo-trace-demo && cd korvo-trace-demo python -m venv venv # Windows venv\Scripts\activate # Linux/macOS source venv/bin/activate安装核心依赖:
pip install chromadb sentence-transformers pydantic # 如果需要使用 OpenAI API pip install openai # 如果需要使用本地 Ollama # 请先安装 Ollama 本体 (https://ollama.com),然后安装其 Python 库 pip install ollama2.2 项目结构设计
一个清晰的项目结构有助于管理代码、配置和溯源数据。建议采用如下布局:
korvo-trace-demo/ ├── docs/ # 存放待处理的本地文档 │ └── employee_handbook.txt ├── storage/ # 本地存储 │ ├── chroma_db/ # ChromaDB 向量数据库数据 │ └── traces/ # 溯源记录存储目录 ├── src/ │ ├── __init__.py │ ├── core/ # 核心逻辑 │ │ ├── __init__.py │ │ ├── tracer.py # 溯源记录器 │ │ ├── retriever.py # 文档检索器 │ │ └── orchestrator.py # 工作流编排器 │ ├── models/ # 数据模型(Pydantic) │ │ ├── __init__.py │ │ └── trace_models.py # 溯源数据模型定义 │ └── utils/ │ ├── __init__.py │ └── file_loader.py # 文档加载工具 ├── config.yaml # 配置文件(模型选择、路径等) ├── main.py # 主入口 └── requirements.txt这个结构将数据处理、AI 调用、溯源记录和业务逻辑进行了分离,符合单一职责原则,便于后续扩展和维护。
3. 定义溯源数据模型
在编写任何业务逻辑之前,我们先使用 Pydantic 定义清晰的溯源数据模型。这是保证溯源信息结构一致、易于序列化存储和查询的基础。
在src/models/trace_models.py中定义:
from datetime import datetime from enum import Enum from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field from uuid import uuid4, UUID class TraceStatus(str, Enum): """溯源记录状态""" STARTED = "started" RETRIEVING = "retrieving" GENERATING = "generating" COMPLETED = "completed" FAILED = "failed" class SourceDocument(BaseModel): """溯源源文档片段""" doc_id: str = Field(description="文档唯一标识") content: str = Field(description="文档片段内容") metadata: Dict[str, Any] = Field(default_factory=dict, description="元数据,如文件名、页码等") score: Optional[float] = Field(default=None, description="检索相关性分数") class ModelInvocation(BaseModel): """模型调用记录""" model_name: str = Field(description="模型名称,如 'gpt-4', 'llama3:8b'") provider: str = Field(description="提供商,如 'openai', 'ollama', 'local'") parameters: Dict[str, Any] = Field(default_factory=dict, description="调用参数,如 temperature, max_tokens") input_tokens: Optional[int] = None output_tokens: Optional[int] = None cost: Optional[float] = None # 如果适用,记录成本 class TraceNode(BaseModel): """溯源链中的一个节点""" node_id: UUID = Field(default_factory=uuid4) node_type: str = Field(description="节点类型,如 'query', 'retrieval', 'prompt', 'model_call', 'tool_call'") content: Any = Field(description="节点内容,可能是字符串、字典或列表") metadata: Dict[str, Any] = Field(default_factory=dict) timestamp: datetime = Field(default_factory=datetime.now) parent_node_id: Optional[UUID] = None # 父节点ID,用于构建树形结构 class TraceInfo(BaseModel): """一次完整 AI 工作流的溯源信息""" trace_id: UUID = Field(default_factory=uuid4) session_id: Optional[str] = None user_query: str status: TraceStatus = TraceStatus.STARTED start_time: datetime = Field(default_factory=datetime.now) end_time: Optional[datetime] = None final_answer: Optional[str] = None source_documents: List[SourceDocument] = Field(default_factory=list) model_invocations: List[ModelInvocation] = Field(default_factory=list) trace_nodes: List[TraceNode] = Field(default_factory=list) # 按时间顺序记录所有节点 error: Optional[str] = None class Config: json_encoders = { datetime: lambda v: v.isoformat(), UUID: lambda v: str(v), }这个模型定义了几个关键实体:
TraceInfo是根对象,代表一次完整的问答会话。TraceNode以链表/树形结构记录了工作流中的每一个步骤,这是实现细粒度溯源的核心。SourceDocument和ModelInvocation记录了外部依赖(知识库和模型)的详细信息。
注意:使用
UUID和datetime可以确保记录的全局唯一性和时序性,这对于后续的查询和审计至关重要。
4. 实现核心组件:检索器、记录器与编排器
有了数据模型,接下来我们实现三个核心组件。
4.1 溯源记录器
记录器负责创建和管理TraceInfo对象,并在工作流的关键节点添加记录。在src/core/tracer.py中实现:
import json from pathlib import Path from typing import Optional from src.models.trace_models import TraceInfo, TraceNode, TraceStatus class TraceRecorder: def __init__(self, storage_path: Path): self.storage_path = storage_path self.storage_path.mkdir(parents=True, exist_ok=True) self.current_trace: Optional[TraceInfo] = None def start_trace(self, user_query: str, session_id: Optional[str] = None) -> TraceInfo: """开始一次新的溯源记录""" self.current_trace = TraceInfo(user_query=user_query, session_id=session_id) self._add_node("query", {"query": user_query}) return self.current_trace def _add_node(self, node_type: str, content: Any, metadata: Optional[dict] = None): """内部方法:向当前 trace 添加一个节点""" if self.current_trace is None: raise RuntimeError("No active trace. Call `start_trace` first.") node = TraceNode( node_type=node_type, content=content, metadata=metadata or {} ) self.current_trace.trace_nodes.append(node) def record_retrieval(self, retrieved_docs: list, metadata: Optional[dict] = None): """记录检索步骤""" self.current_trace.source_documents.extend(retrieved_docs) self._add_node("retrieval", {"documents": [doc.dict() for doc in retrieved_docs]}, metadata) self.current_trace.status = TraceStatus.RETRIEVING def record_prompt(self, full_prompt: str, metadata: Optional[dict] = None): """记录发送给模型的完整提示词""" self._add_node("prompt", {"prompt": full_prompt}, metadata) def record_model_call(self, model_invocation, metadata: Optional[dict] = None): """记录模型调用""" self.current_trace.model_invocations.append(model_invocation) self._add_node("model_call", model_invocation.dict(), metadata) self.current_trace.status = TraceStatus.GENERATING def record_final_answer(self, answer: str): """记录最终答案并完成溯源""" self.current_trace.final_answer = answer self.current_trace.status = TraceStatus.COMPLETED self.current_trace.end_time = datetime.now() self._add_node("final_answer", {"answer": answer}) self._save_trace() def record_error(self, error_msg: str): """记录错误信息""" self.current_trace.error = error_msg self.current_trace.status = TraceStatus.FAILED self.current_trace.end_time = datetime.now() self._add_node("error", {"error": error_msg}) self._save_trace() def _save_trace(self): """将当前溯源记录保存到本地文件""" if self.current_trace is None: return trace_file = self.storage_path / f"{self.current_trace.trace_id}.json" with open(trace_file, 'w', encoding='utf-8') as f: # 使用模型的 json() 方法确保序列化正确 f.write(self.current_trace.json(indent=2, ensure_ascii=False)) print(f"[Tracer] Trace saved to: {trace_file}")记录器提供了清晰的方法来记录工作流的每一步,并将最终结果以 JSON 格式保存到本地storage/traces/目录下。每个文件都以trace_id命名,便于查找。
4.2 本地文档检索器
检索器负责加载本地文档,创建向量索引,并根据查询返回最相关的文档片段。在src/core/retriever.py中实现:
import chromadb from chromadb.config import Settings from sentence_transformers import SentenceTransformer from typing import List from src.models.trace_models import SourceDocument class LocalRetriever: def __init__(self, docs_path: Path, db_path: Path, embedding_model_name: str = 'all-MiniLM-L6-v2'): """ 初始化本地检索器。 :param docs_path: 存放文档的目录 :param db_path: ChromaDB 持久化路径 :param embedding_model_name: 句子嵌入模型名称 """ self.docs_path = docs_path self.embedding_model = SentenceTransformer(embedding_model_name) # 初始化 Chroma 客户端,持久化到本地目录 self.client = chromadb.PersistentClient(path=str(db_path), settings=Settings(anonymized_telemetry=False)) self.collection = self.client.get_or_create_collection(name="local_docs") self._initialize_db() def _initialize_db(self): """如果集合为空,则从 docs_path 加载文档并创建索引""" if self.collection.count() == 0: print("[Retriever] Initializing vector database from local documents...") from src.utils.file_loader import load_and_chunk_text_files documents, metadatas, ids = load_and_chunk_text_files(self.docs_path) if documents: embeddings = self.embedding_model.encode(documents).tolist() self.collection.add( embeddings=embeddings, documents=documents, metadatas=metadatas, ids=ids ) print(f"[Retriever] Loaded {len(documents)} chunks into database.") def retrieve(self, query: str, top_k: int = 3) -> List[SourceDocument]: """检索与查询最相关的文档片段""" query_embedding = self.embedding_model.encode([query]).tolist()[0] results = self.collection.query( query_embeddings=[query_embedding], n_results=top_k ) retrieved_docs = [] if results['documents']: for i, doc in enumerate(results['documents'][0]): metadata = results['metadatas'][0][i] if results['metadatas'] else {} distance = results['distances'][0][i] if results['distances'] else None # 将距离转换为相似度分数(可选,Chroma 默认使用余弦距离) score = 1 - distance if distance is not None else None retrieved_docs.append( SourceDocument( doc_id=results['ids'][0][i], content=doc, metadata=metadata, score=score ) ) return retrieved_docs这里我们使用sentence-transformers生成嵌入向量,用ChromaDB存储和检索。load_and_chunk_text_files是一个工具函数,负责读取文本文件并按固定长度分块(例如每 500 字符一块),并为每个块生成元数据(如来源文件名)。这部分代码在src/utils/file_loader.py中,因篇幅所限不展开,其核心是简单的文件读取和文本分割。
4.3 工作流编排器
编排器是大脑,它串联起检索、提示词构建、模型调用和溯源记录。在src/core/orchestrator.py中实现:
from typing import Optional from src.core.retriever import LocalRetriever from src.core.tracer import TraceRecorder from src.models.trace_models import ModelInvocation, TraceInfo class Orchestrator: def __init__(self, retriever: LocalRetriever, tracer: TraceRecorder, model_type: str = "openai"): self.retriever = retriever self.tracer = tracer self.model_type = model_type # 根据配置初始化模型客户端 if model_type == "openai": from openai import OpenAI self.client = OpenAI() # 假设 API Key 已设置环境变量 OPENAI_API_KEY elif model_type == "ollama": import ollama self.client = ollama else: self.client = None def answer_with_trace(self, query: str, session_id: Optional[str] = None) -> TraceInfo: """主流程:回答问题并生成完整溯源记录""" # 1. 开始溯源 trace = self.tracer.start_trace(query, session_id) try: # 2. 检索相关文档 retrieved_docs = self.retriever.retrieve(query) self.tracer.record_retrieval(retrieved_docs, metadata={"top_k": 3}) # 3. 构建提示词 context = "\n\n".join([doc.content for doc in retrieved_docs]) prompt = self._build_prompt(query, context) self.tracer.record_prompt(prompt, metadata={"template": "qa_with_context"}) # 4. 调用模型 model_invocation, answer = self._call_model(prompt) self.tracer.record_model_call(model_invocation) # 5. 记录最终答案 self.tracer.record_final_answer(answer) return trace except Exception as e: self.tracer.record_error(str(e)) raise def _build_prompt(self, query: str, context: str) -> str: """构建提示词模板""" prompt_template = """ 请基于以下上下文信息回答问题。如果上下文信息不足以回答问题,请直接说“根据已有信息无法回答”。 上下文信息: {context} 问题:{query} 请给出清晰、准确的答案: """ return prompt_template.format(context=context, query=query) def _call_model(self, prompt: str): """根据配置调用不同的模型""" common_params = {"temperature": 0.1, "max_tokens": 500} model_invocation = None answer = "" if self.model_type == "openai": response = self.client.chat.completions.create( model="gpt-3.5-turbo", messages=[{"role": "user", "content": prompt}], **common_params ) answer = response.choices[0].message.content model_invocation = ModelInvocation( model_name="gpt-3.5-turbo", provider="openai", parameters=common_params, input_tokens=response.usage.prompt_tokens, output_tokens=response.usage.completion_tokens ) elif self.model_type == "ollama": response = self.client.chat( model="llama3:8b", messages=[{"role": "user", "content": prompt}], options=common_params ) answer = response['message']['content'] model_invocation = ModelInvocation( model_name="llama3:8b", provider="ollama", parameters=common_params ) else: # 模拟一个本地模型调用,实际项目中可替换为 transformers 库调用 answer = f"[模拟本地模型] 基于您提供的上下文,答案是:这是一个模拟响应。" model_invocation = ModelInvocation( model_name="mock-local-model", provider="local", parameters=common_params ) return model_invocation, answer编排器类清晰地定义了问答的流水线。通过依赖注入retriever和tracer,它本身不关心数据如何存储或记录如何保存,只负责业务流程,这符合单一职责原则。
5. 运行验证与结果分析
现在,我们将所有组件组装起来,运行一个完整的示例。
5.1 准备文档与配置文件
首先,在docs/employee_handbook.txt中放入一些示例文本:
公司年假制度 1. 员工入职满一年后,享有每年15天的带薪年假。 2. 年假可以分次休,但最小请假单位为0.5天。 3. 年假有效期至次年3月31日,逾期未休视为自动放弃。 报销流程 1. 员工需在费用发生后的30天内提交报销申请。 2. 报销单需直属上级和财务部审批。 3. 审批通过后,款项将在14个工作日内支付至工资卡。然后,创建一个简单的config.yaml:
storage: vector_db_path: "./storage/chroma_db" trace_storage_path: "./storage/traces" docs_path: "./docs" model: type: "openai" # 可选: "openai", "ollama", "mock" embedding: "all-MiniLM-L6-v2" retrieval: top_k: 35.2 编写主程序入口
在main.py中,我们读取配置并启动问答流程:
import yaml from pathlib import Path from src.core.retriever import LocalRetriever from src.core.tracer import TraceRecorder from src.core.orchestrator import Orchestrator def load_config(): with open('config.yaml', 'r', encoding='utf-8') as f: return yaml.safe_load(f) def main(): config = load_config() # 初始化组件 retriever = LocalRetriever( docs_path=Path(config['storage']['docs_path']), db_path=Path(config['storage']['vector_db_path']), embedding_model_name=config['model']['embedding'] ) tracer = TraceRecorder(storage_path=Path(config['storage']['trace_storage_path'])) orchestrator = Orchestrator( retriever=retriever, tracer=tracer, model_type=config['model']['type'] ) # 示例查询 query = "公司的年假有多少天?" print(f"用户问题: {query}") try: trace = orchestrator.answer_with_trace(query, session_id="test_session_001") print(f"\nAI 回答: {trace.final_answer}") print(f"\n溯源记录已保存,Trace ID: {trace.trace_id}") print(f"检索到 {len(trace.source_documents)} 个相关文档片段。") print(f"进行了 {len(trace.model_invocations)} 次模型调用。") except Exception as e: print(f"处理过程中发生错误: {e}") if __name__ == "__main__": main()5.3 执行与输出
运行程序前,请确保已正确设置环境变量(如OPENAI_API_KEY)或启动了本地 Ollama 服务。
python main.py预期输出如下:
[Retriever] Initializing vector database from local documents... [Tracer] Trace saved to: ./storage/traces/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.json 用户问题: 公司的年假有多少天? AI 回答: 根据提供的上下文信息,员工入职满一年后,享有每年15天的带薪年假。 溯源记录已保存,Trace ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 检索到 3 个相关文档片段。 进行了 1 次模型调用。5.4 分析溯源记录
程序运行后,在storage/traces/目录下会生成一个 JSON 文件。其内容结构如下(已简化):
{ "trace_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "user_query": "公司的年假有多少天?", "status": "completed", "start_time": "2024-05-27T10:00:00", "end_time": "2024-05-27T10:00:05", "final_answer": "根据提供的上下文信息,员工入职满一年后,享有每年15天的带薪年假。", "source_documents": [ { "doc_id": "employee_handbook_0", "content": "公司年假制度\n1. 员工入职满一年后,享有每年15天的带薪年假。\n2. 年假可以分次休,但最小请假单位为0.5天。", "metadata": {"source_file": "employee_handbook.txt", "chunk_index": 0}, "score": 0.92 } ], "model_invocations": [ { "model_name": "gpt-3.5-turbo", "provider": "openai", "parameters": {"temperature": 0.1, "max_tokens": 500}, "input_tokens": 120, "output_tokens": 25 } ], "trace_nodes": [ { "node_id": "node_1", "node_type": "query", "content": {"query": "公司的年假有多少天?"}, "timestamp": "2024-05-27T10:00:00" }, { "node_id": "node_2", "node_type": "retrieval", "content": {"documents": [...]}, "timestamp": "2024-05-27T10:00:01" }, { "node_id": "node_3", "node_type": "prompt", "content": {"prompt": "请基于以下上下文信息回答问题..."}, "timestamp": "2024-05-27T10:00:02" }, { "node_id": "node_4", "node_type": "model_call", "content": {...}, "timestamp": "2024-05-27T10:00:03" }, { "node_id": "node_5", "node_type": "final_answer", "content": {"answer": "根据提供的上下文信息..."}, "timestamp": "2024-05-27T10:00:04" } ] }这份 JSON 记录就是“答案溯源”的成果。通过它,我们可以清晰地看到:
- 答案来源于哪个文档片段(
source_documents)。 - 模型调用消耗了多少资源(
model_invocations)。 - 整个问答过程经历了哪些步骤,每一步发生了什么(
trace_nodes)。 - 整个过程耗时多少(
start_time,end_time)。
6. 常见问题排查与优化
在实际部署和运行此类系统时,会遇到各种问题。以下是三个最常见的坑及其解决方案。
6.1 检索结果不相关或为空
现象:AI 回答“根据已有信息无法回答”,或者答案明显与问题无关。可能原因与排查:
- 文档未正确索引:检查
storage/chroma_db目录是否已创建,并确认retriever._initialize_db()的日志是否显示成功加载了文档块。可以手动查询向量数据库来验证:# 临时调试脚本 import chromadb client = chromadb.PersistentClient(path="./storage/chroma_db") collection = client.get_collection("local_docs") print(f"Total chunks in DB: {collection.count()}") results = collection.get() if results['documents']: print("First chunk:", results['documents'][0][:200]) # 打印前200字符 - 文本分块策略不当:如果文档块太大或太小,都可能影响检索精度。检查
file_loader.py中的分块逻辑(如块大小、重叠区域)。对于纯文本,500-1000 字符的块大小配合 50-100 字符的重叠是一个不错的起点。 - 嵌入模型不匹配:
sentence-transformers模型all-MiniLM-L6-v2适用于通用语义匹配。如果领域特殊(如法律、医学),考虑使用在该领域微调过的模型。 - 查询表述问题:用户问题可能过于口语化或简短。可以尝试在检索前对查询进行简单的重写或扩展(Query Expansion),例如使用大模型将“年假多少天?”重写为“公司年假制度规定的带薪年假天数”。
6.2 模型调用失败或超时
现象:程序卡住或抛出网络连接、认证错误。可能原因与排查:
| 模型类型 | 常见错误 | 检查点 |
|---|---|---|
| OpenAI API | AuthenticationError,RateLimitError,APIConnectionError | 1. 环境变量OPENAI_API_KEY是否正确设置。2. 网络是否能访问 api.openai.com。3. 账户是否有余额或额度。 |
| 本地 Ollama | ConnectionRefusedError, 模型未下载 | 1. 终端执行ollama serve确保服务已启动。2. 执行 ollama list确认所需模型(如llama3:8b)已下载。3. 检查 main.py中model_type配置是否为"ollama"。 |
| 通用 | 超时 | 在代码中为网络请求设置合理的timeout参数(如 30 秒),并添加重试机制。 |
解决方案:在orchestrator._call_model方法中增加健壮性处理:
import time from tenacity import retry, stop_after_attempt, wait_exponential class Orchestrator: # ... 其他代码 ... @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10)) def _call_model_with_retry(self, prompt: str): """带重试机制的模型调用""" # 将原有的 _call_model 逻辑移到这里 return self._call_model(prompt) def answer_with_trace(self, query: str, session_id: Optional[str] = None) -> TraceInfo: # ... 其他步骤 ... try: model_invocation, answer = self._call_model_with_retry(prompt) except Exception as e: self.tracer.record_error(f"Model call failed after retries: {e}") # 可以提供一个降级答案 answer = "系统暂时无法处理您的请求,请稍后再试。" model_invocation = ModelInvocation(...) # 记录一次失败的调用 # ... 记录结果 ...6.3 溯源记录文件过大或查询慢
现象:随着使用,storage/traces/目录下文件越来越多,加载单个 trace 或进行统计分析变慢。可能原因与排查:
- 存储了过多冗余信息:检查
TraceNode的content字段是否存储了过大的原始数据(如完整的 embedding 数组)。 - 序列化格式低效:JSON 虽然可读性好,但对于大量小文件,其解析和存储效率并非最优。
- 缺乏归档和清理策略:所有记录永久保存。
优化建议:
- 精简存储内容:在
TraceNode中,对于大型数据,只存储引用 ID 或路径,而非完整内容。class TraceNode(BaseModel): node_type: str content_ref: Optional[str] = None # 例如,指向外部文件的路径或数据库 ID content_summary: str # 存储一个简短的文本摘要,用于快速浏览 # ... 其他字段 - 选择更高效的序列化格式:对于生产环境,可以考虑使用
MessagePack、Parquet或直接存入 SQLite/小型数据库(如DuckDB)。 - 实施数据生命周期管理:
- 按时间分片:按年/月创建子目录存储 trace 文件。
- 自动清理:设置保留策略,例如只保留最近 90 天的详细记录,更早的记录可以聚合摘要后删除原始文件。
- 冷热分离:近期高频查询的 trace 放在 SSD,历史 trace 归档到对象存储。
7. 生产环境最佳实践与扩展方向
将上述概念验证系统用于实际生产,还需要考虑更多工程化因素。
7.1 安全与隐私强化
在本地优先的架构中,安全重心从网络边界转移到终端和数据本身。
- 敏感信息脱敏:在文档加载 (
file_loader) 或检索结果返回前,使用正则或 NLP 模型识别并脱敏个人信息(如身份证号、手机号)。 - 溯源记录加密:如果 trace 文件可能被同步到云端,应对其进行加密存储。可以考虑使用
cryptography库,基于本地生成的密钥进行加密。 - 最小权限访问:确保存储溯源记录的目录(
storage/)有适当的文件系统权限,防止未授权读取。
7.2 可观测性与监控
一个健壮的 AI 工作空间需要知道自身运行状况。
- 集成日志框架:使用
structlog或logging模块,为不同组件(检索器、模型调用、溯源器)设置不同日志级别,并输出到文件。 - 添加关键指标:在
TraceInfo中或单独收集以下指标:- 请求延迟(
end_time - start_time) - 检索耗时
- 模型调用耗时和 Token 消耗
- 答案长度
- 溯源记录大小
- 请求延迟(
- 设置健康检查:可以创建一个简单的 HTTP 端点或命令行检查,验证向量数据库连接、模型服务可用性和存储空间。
7.3 扩展为多 Agent 工作流
当前的流水线是线性的(检索 -> 生成)。真正的“工作空间”往往涉及多个 AI Agent 协作。
- 定义 Agent 角色:例如,一个
ResearchAgent负责检索,一个AnalysisAgent负责总结,一个ValidationAgent负责核查事实。 - 在溯源中记录协作:扩展
TraceNode的node_type,增加agent_decision,tool_call,agent_handoff等类型。每个 Agent 的动作都作为一个节点记录,并通过parent_node_id形成树形结构,清晰展示工作流的决策路径。 - 示例节点:
{ "node_type": "agent_handoff", "content": { "from_agent": "ResearchAgent", "to_agent": "AnalysisAgent", "handoff_data": "找到了3份相关文档..." } }
7.4 构建溯源查询界面
存储了结构化 trace 数据后,可以构建一个简单的内部工具来查询和可视化这些数据。
- 后端 API:使用 FastAPI 提供按
trace_id、session_id、时间范围或问题关键词查询 trace 的接口。 - 前端界面:一个简单的 React/Vue 页面,以时间线或树形图的形式展示
trace_nodes,并高亮显示source_documents和答案的对应关系。 - 核心查询示例(使用 DuckDB):
-- 找出所有使用了特定文档的问答记录 SELECT trace_id, user_query, final_answer FROM read_json_auto('./storage/traces/*.json') WHERE list_contains(source_documents.doc_id, 'employee_handbook_0');
通过以上步骤,我们从一个简单的问答溯源 demo 出发,探讨了将其工程化、产品化所需考虑的关键点。本地优先 AI 工作空间的构建是一个持续迭代的过程,核心在于在灵活性、可控性和用户体验之间找到平衡。而强大的答案溯源能力,是建立用户信任、实现可靠调试和持续优化 AI 应用的基石。