Research note / 2026/07/07

项目实战-智能文档助手

Agent应用开发 · Day 7 学习笔记

项目实战-智能文档助手封面

日期:2026-06-09
主题:综合实战 — 从零搭建一个完整 Agent 应用
进度:40/40 概念(全部掌握)


实战项目:智能文档助手

一个能读取用户上传的文档、回答问题、执行相关操作的 Agent

整合能力清单

  • ✅ 系统提示词设计(Day 2)
  • ✅ ReAct 架构(Day 3)
  • ✅ Function Calling + 多工具调用(Day 4)
  • ✅ RAG 文档检索(Day 4)
  • ✅ 对话记忆管理(Day 5)
  • ✅ 错误处理与重试(Day 4)
  • ✅ 基本的可观测性(Day 6)

一、需求分析与架构设计

功能需求

用户上传文档(PDF/TXT/MD)
  → Agent 解析并索引文档
  → 用户可以对文档提问
  → Agent 基于文档内容回答
  → Agent 可以执行额外操作(搜索、计算、总结)
  → 支持多轮对话,记住上下文

技术架构

┌─────────────────────────────────────────────────┐
│                    前端 (Streamlit)               │
│  文件上传 │ 对话界面 │ 中间状态展示                  │
└──────────────────────┬──────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────┐
│                  Agent 核心                       │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐       │
│  │ 记忆管理  │  │ ReAct循环 │  │ Trace记录│       │
│  └──────────┘  └──────────┘  └──────────┘       │
│       │              │                           │
│  ┌────▼────┐   ┌────▼────┐                      │
│  │ 对话历史 │   │ 工具调度 │                      │
│  │ 长期记忆 │   │ RAG检索 │                      │
│  └─────────┘   └─────────┘                      │
└──────────────────────┬──────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────┐
│                  工具层                           │
│  文档检索 │ 网页搜索 │ 计算器 │ 文本摘要           │
└──────────────────────┬──────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────┐
│                  存储层                           │
│  ChromaDB (向量) │ 文件系统 │ JSON (记忆)         │
└─────────────────────────────────────────────────┘

技术选型

组件选择理由LLMOpenAI GPT-4oFunction Calling 支持好向量数据库ChromaDB轻量、Python 原型友好EmbeddingOpenAI text-embedding-3-small性价比高文档解析PyPDF2 + 原生读取覆盖 PDF/TXT/MD前端Streamlit快速搭建、Python 全栈框架不依赖框架,原生实现学习原理


二、项目结构

doc-assistant/
├── app.py                 # Streamlit 前端入口
├── agent/
│   ├── __init__.py
│   ├── core.py            # Agent 核心(ReAct 循环)
│   ├── memory.py          # 记忆管理
│   └── tracer.py          # Trace 记录
├── tools/
│   ├── __init__.py
│   ├── registry.py        # 工具注册表
│   ├── doc_search.py      # 文档检索工具
│   ├── web_search.py      # 网页搜索工具
│   ├── calculator.py      # 计算器工具
│   └── summarizer.py      # 文本摘要工具
├── rag/
│   ├── __init__.py
│   ├── chunker.py         # 文档分块
│   ├── embedder.py        # Embedding 封装
│   └── retriever.py       # 向量检索
├── config.py              # 配置管理
├── requirements.txt
└── .env                   # API Key

三、核心模块实现

3.1 配置管理

# config.py
import os
from dataclasses import dataclass
from dotenv import load_dotenv

load_dotenv()

@dataclass(frozen=True)
class Config:
    openai_api_key: str = os.getenv("OPENAI_API_KEY", "")
    model: str = "gpt-4o"
    embedding_model: str = "text-embedding-3-small"
    max_agent_steps: int = 5
    chunk_size: int = 500
    chunk_overlap: int = 50
    top_k_results: int = 3
    max_history_turns: int = 10

    def validate(self):
        if not self.openai_api_key:
            raise ValueError("OPENAI_API_KEY 未配置")

config = Config()
config.validate()

3.2 工具注册表

# tools/registry.py
from typing import Callable, Dict, Any

class Tool:
    def __init__(self, name: str, description: str, func: Callable, parameters: dict):
        self.name = name
        self.description = description
        self.func = func
        self.parameters = parameters

    def to_schema(self) -> dict:
        return {
            "type": "function",
            "function": {
                "name": self.name,
                "description": self.description,
                "parameters": self.parameters
            }
        }

    def execute(self, **kwargs) -> str:
        try:
            return self.func(**kwargs)
        except Exception as e:
            return f"工具执行失败:{str(e)}"

class ToolRegistry:
    def __init__(self):
        self._tools: Dict[str, Tool] = {}

    def register(self, tool: Tool):
        self._tools[tool.name] = tool

    def get(self, name: str) -> Tool | None:
        return self._tools.get(name)

    def get_all_schemas(self) -> list:
        return [t.to_schema() for t in self._tools.values()]

    def get_all_names(self) -> list:
        return list(self._tools.keys())

# 全局注册表
registry = ToolRegistry()

3.3 文档检索工具

# tools/doc_search.py
from tools.registry import Tool, registry

class DocSearchTool:
    def __init__(self, retriever):
        self.retriever = retriever

    def search(self, query: str) -> str:
        results = self.retriever.retrieve(query, top_k=3)
        if not results:
            return "未找到相关文档内容。"
        return "\n\n".join([f"[文档片段 {i+1}]\n{r}" for i, r in enumerate(results)])

# 注册
doc_search = DocSearchTool(retriever)
registry.register(Tool(
    name="search_document",
    description="在用户上传的文档中搜索相关信息。当用户询问文档相关问题时使用。",
    func=doc_search.search,
    parameters={
        "type": "object",
        "properties": {
            "query": {
                "type": "string",
                "description": "搜索查询,用自然语言描述要找的内容"
            }
        },
        "required": ["query"]
    }
))

3.4 RAG 检索器

# rag/retriever.py
import chromadb
from openai import OpenAI
from config import config

class Retriever:
    def __init__(self):
        self.client = chromadb.Client()
        self.collection = self.client.create_collection("documents")
        self.openai = OpenAI(api_key=config.openai_api_key)

    def add_documents(self, chunks: list[str], metadatas: list[dict]):
        embeddings = self._get_embeddings(chunks)
        ids = [f"doc_{i}" for i in range(len(chunks))]
        self.collection.add(
            documents=chunks,
            embeddings=embeddings,
            metadatas=metadatas,
            ids=ids
        )

    def retrieve(self, query: str, top_k: int = 3) -> list[str]:
        query_embedding = self._get_embeddings([query])[0]
        results = self.collection.query(
            query_embeddings=[query_embedding],
            n_results=top_k
        )
        return results["documents"][0] if results["documents"] else []

    def _get_embeddings(self, texts: list[str]) -> list[list[float]]:
        response = self.openai.embeddings.create(
            model=config.embedding_model,
            input=texts
        )
        return [item.embedding for item in response.data]

3.5 文档分块器

# rag/chunker.py
from config import config

class Chunker:
    def __init__(self, chunk_size: int = None, overlap: int = None):
        self.chunk_size = chunk_size or config.chunk_size
        self.overlap = overlap or config.chunk_overlap

    def chunk(self, text: str, source: str = "unknown") -> list[dict]:
        # 递归分块:先按段落,再按句子,最后按字符
        chunks = self._recursive_split(text)
        return [
            {
                "text": chunk,
                "metadata": {"source": source, "index": i}
            }
            for i, chunk in enumerate(chunks)
        ]

    def _recursive_split(self, text: str) -> list[str]:
        if len(text) <= self.chunk_size:
            return [text.strip()] if text.strip() else []

        # 尝试按分隔符切分
        for separator in ["\n\n", "\n", "。", ".", " "]:
            if separator in text:
                parts = text.split(separator)
                chunks = []
                current = ""
                for part in parts:
                    if len(current) + len(part) + len(separator) <= self.chunk_size:
                        current += part + separator
                    else:
                        if current.strip():
                            chunks.append(current.strip())
                        current = part + separator
                if current.strip():
                    chunks.append(current.strip())
                return chunks

        # 兜底:按字符切分
        return [text[i:i+self.chunk_size] for i in range(0, len(text), self.chunk_size - self.overlap)]

3.6 记忆管理

# agent/memory.py
import json
from pathlib import Path
from openai import OpenAI
from config import config

class MemoryManager:
    def __init__(self, user_id: str = "default"):
        self.user_id = user_id
        self.short_term: list[dict] = []  # 对话历史
        self.long_term: list[dict] = []   # 持久化记忆
        self.openai = OpenAI(api_key=config.openai_api_key)
        self._load_long_term()

    def add_message(self, role: str, content: str):
        self.short_term.append({"role": role, "content": content})
        # 自动压缩
        if len(self.short_term) > config.max_history_turns * 2:
            self._compress_history()

    def get_history(self) -> list[dict]:
        return self.short_term

    def get_context(self, user_input: str) -> str:
        # 检索相关长期记忆
        relevant = self._recall(user_input)
        if relevant:
            return "用户相关信息:\n" + "\n".join(f"- {m}" for m in relevant)
        return ""

    def save_to_long_term(self, content: str):
        self.long_term.append({"content": content, "user_id": self.user_id})
        self._persist_long_term()

    def _compress_history(self):
        old = self.short_term[:-6]
        recent = self.short_term[-6:]

        summary = self.openai.chat.completions.create(
            model="gpt-4o-mini",
            messages=[
                {"role": "system", "content": "将以下对话压缩为简短摘要,保留关键信息。"},
                {"role": "user", "content": json.dumps(old, ensure_ascii=False)}
            ],
            max_tokens=300
        )

        self.short_term = [
            {"role": "system", "content": f"对话摘要:{summary.choices[0].message.content}"}
        ] + recent

    def _recall(self, query: str) -> list[str]:
        # 简单的关键词匹配(生产环境用向量检索)
        return [m["content"] for m in self.long_term if self.user_id == m.get("user_id")][:3]

    def _load_long_term(self):
        path = Path(f"data/memory_{self.user_id}.json")
        if path.exists():
            self.long_term = json.loads(path.read_text())

    def _persist_long_term(self):
        path = Path(f"data/memory_{self.user_id}.json")
        path.parent.mkdir(exist_ok=True)
        path.write_text(json.dumps(self.long_term, ensure_ascii=False, indent=2))

3.7 Trace 记录

# agent/tracer.py
import time
import json
from pathlib import Path
from contextlib import contextmanager

class Tracer:
    def __init__(self, trace_id: str):
        self.trace_id = trace_id
        self.steps: list[dict] = []
        self.start_time = time.time()
        self.total_tokens = 0

    @contextmanager
    def step(self, step_type: str, **kwargs):
        step_data = {"type": step_type, "start": time.time(), **kwargs}
        try:
            yield step_data
        except Exception as e:
            step_data["error"] = str(e)
            raise
        finally:
            step_data["duration_ms"] = round((time.time() - step_data["start"]) * 1000)
            self.steps.append(step_data)

    def add_tokens(self, count: int):
        self.total_tokens += count

    def save(self):
        trace = {
            "trace_id": self.trace_id,
            "total_duration_ms": round((time.time() - self.start_time) * 1000),
            "total_tokens": self.total_tokens,
            "steps": self.steps
        }
        path = Path(f"traces/{self.trace_id}.json")
        path.parent.mkdir(exist_ok=True)
        path.write_text(json.dumps(trace, ensure_ascii=False, indent=2))

3.8 Agent 核心(ReAct 循环)

# agent/core.py
import json
import uuid
from openai import OpenAI
from config import config
from tools.registry import registry
from agent.memory import MemoryManager
from agent.tracer import Tracer

class DocumentAgent:
    def __init__(self, user_id: str = "default"):
        self.openai = OpenAI(api_key=config.openai_api_key)
        self.memory = MemoryManager(user_id)
        self.tracer = None

    def chat(self, user_input: str) -> str:
        self.tracer = Tracer(trace_id=str(uuid.uuid4())[:8])
        self.memory.add_message("user", user_input)

        # 获取用户相关的长期记忆
        memory_context = self.memory.get_context(user_input)

        # 构建消息
        messages = self._build_messages(user_input, memory_context)

        # ReAct 循环
        response = self._react_loop(messages)

        self.memory.add_message("assistant", response)
        self.tracer.save()
        return response

    def _build_messages(self, user_input: str, memory_context: str) -> list[dict]:
        system_prompt = """你是一个智能文档助手。你可以:
1. 基于用户上传的文档回答问题(使用 search_document 工具)
2. 进行网页搜索补充信息
3. 执行数学计算
4. 对长文本进行摘要

行为规则:
- 先搜索文档,再回答。不要编造文档中没有的信息。
- 如果文档中找不到答案,如实告知用户。
- 回答简洁准确,引用文档原文时标注来源。

请使用 Thought → Action → Observation 的方式思考和执行。"""

        if memory_context:
            system_prompt += f"\n\n{memory_context}"

        messages = [{"role": "system", "content": system_prompt}]
        messages.extend(self.memory.get_history())
        return messages

    def _react_loop(self, messages: list[dict]) -> str:
        for step in range(config.max_agent_steps):
            with self.tracer.step("llm_call", step=step) as s:
                response = self.openai.chat.completions.create(
                    model=config.model,
                    messages=messages,
                    tools=registry.get_all_schemas(),
                    tool_choice="auto"
                )
                message = response.choices[0].message
                self.tracer.add_tokens(response.usage.total_tokens)
                s["tokens"] = response.usage.total_tokens

            # 没有工具调用,直接返回文本
            if not message.tool_calls:
                return message.content

            # 处理工具调用
            messages.append(message)
            for tool_call in message.tool_calls:
                func_name = tool_call.function.name
                func_args = json.loads(tool_call.function.arguments)

                with self.tracer.step("tool_call", tool=func_name, args=func_args) as s:
                    tool = registry.get(func_name)
                    if tool:
                        result = tool.execute(**func_args)
                    else:
                        result = f"错误:工具 '{func_name}' 不存在"
                    s["result"] = result[:200]

                messages.append({
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": result
                })

        return "抱歉,我尝试了多种方式但无法完成任务。请尝试简化您的问题。"

四、前端界面

# app.py
import streamlit as st
from pathlib import Path
from agent.core import DocumentAgent
from rag.chunker import Chunker
from rag.retriever import Retriever

st.set_page_config(page_title="智能文档助手", layout="wide")
st.title("📄 智能文档助手")

# 初始化
if "agent" not in st.session_state:
    st.session_state.agent = DocumentAgent()
    st.session_state.retriever = Retriever()
    st.session_state.chunker = Chunker()

# 侧边栏:文档上传
with st.sidebar:
    st.header("📁 上传文档")
    uploaded_file = st.file_uploader("选择文件", type=["pdf", "txt", "md"])

    if uploaded_file:
        with st.spinner("正在处理文档..."):
            # 读取文件内容
            if uploaded_file.type == "application/pdf":
                import PyPDF2
                reader = PyPDF2.PdfReader(uploaded_file)
                content = "\n".join(page.extract_text() for page in reader.pages)
            else:
                content = uploaded_file.read().decode("utf-8")

            # 分块并索引
            chunks = st.session_state.chunker.chunk(content, source=uploaded_file.name)
            st.session_state.retriever.add_documents(
                chunks=[c["text"] for c in chunks],
                metadatas=[c["metadata"] for c in chunks]
            )
            st.success(f"文档已索引:{len(chunks)} 个片段")

# 对话界面
for msg in st.session_state.get("messages", []):
    with st.chat_message(msg["role"]):
        st.write(msg["content"])

user_input = st.chat_input("输入你的问题...")
if user_input:
    st.session_state.messages = st.session_state.get("messages", [])
    st.session_state.messages.append({"role": "user", "content": user_input})

    with st.chat_message("user"):
        st.write(user_input)

    with st.chat_message("assistant"):
        with st.spinner("思考中..."):
            response = st.session_state.agent.chat(user_input)
            st.write(response)

    st.session_state.messages.append({"role": "assistant", "content": response})

五、运行与测试

安装依赖

pip install openai chromadb streamlit PyPDF2 python-dotenv

配置环境变量

# .env
OPENAI_API_KEY=sk-your-key-here

启动应用

streamlit run app.py

测试用例

测试1:文档问答
  上传一篇技术文档 → 提问"这篇文章的主要观点是什么?"
  预期:基于文档内容回答,不编造

测试2:多轮对话
  问"文档中提到了哪些技术?" → 再问"这些技术的优缺点是什么?"
  预期:第二轮能理解"这些技术"指代第一轮的结果

测试3:工具选择
  问"123 * 456 等于多少?"
  预期:调用计算器工具,不自己算

测试4:文档外问题
  问"今天天气怎么样?"(文档中没有天气信息)
  预期:告知文档中没有相关信息

六、Day 7 核心收获

从 7 天学习中提炼的 Agent 开发心法

  1. Agent = LLM + 循环 + 工具:这是最小定义,缺一不可
  2. Prompt 是最核心的工程:80% 的问题可以通过改进 Prompt 解决
  3. 工具描述决定工具选择:花时间写好 description,比调参数有用
  4. 错误处理比正常流程更重要:Agent 的非决定性意味着什么错误都可能发生
  5. 记忆是 Agent 的灵魂:没有记忆的 Agent 只是高级 chatbot
  6. RAG 是 Agent 的知识库:让 Agent 能访问私有数据的关键技术
  7. Trace 是调试的生命线:没有 Trace,出了问题你都不知道发生了什么
  8. 安全是底线:Prompt 注入、权限管控、危险操作确认,一个都不能少
  9. 成本控制是生存线:Token 就是钱,精打细算才能持续运行
  10. 迭代是唯一的方法论:没有一步到位的 Agent,只有不断改进的 Agent

7 天知识图谱回顾

Day 1: 认知地基          Agent 是什么 + LLM 基础
Day 2: 对话的艺术        Prompt Engineering
Day 3: 思维方式          Agent 架构模式(ReAct / Plan / Reflexion)
Day 4: 手和眼            工具使用 + RAG
Day 5: 记忆与协作        记忆系统 + 多 Agent
Day 6: 质量与上线        评估调试 + 部署工程化
Day 7: 综合实战          从零搭建完整 Agent ← 你在这里

下一步方向

方向学习内容深入 RAG混合检索、重排序、多模态 RAG生产部署Docker、K8s、监控告警高级多 AgentAutoGen、CrewAI、复杂协作模式评估体系自动化评估流水线、LLM-as-Judge垂直领域代码 Agent、数据分析 Agent、客服 Agent

Discussion

评论

评论暂未开放

站点尚未配置 GitHub Discussions。文章阅读不受影响,也不会再发起无效的 GitHub 登录请求。