第1~15天:原生Agent、RAG与LangGraph基础(完整代码实操)

第1~15天:原生Agent、RAG与LangGraph基础(完整代码实操)

这不是知识目录。每天都包含:具体学习内容、需要创建的文件、可运行代码、执行命令、预期结果、故障练习、AI Coding提示词和验收标准。


使用方法

每天按下面顺序执行,不要一次让 AI 完成整天内容:

text 复制代码
1. 阅读"今天必须理解"
2. 手动画流程或数据结构
3. 输入当天第一段代码
4. 运行并观察结果
5. 再让AI协助补下一段
6. 执行故障练习
7. 运行验收命令
8. 不看代码复述执行流程

AI Coding统一规则:

text 复制代码
- AI修改前必须先阅读相关文件。
- 一次只实现当天一个步骤。
- 每次修改后必须展示Diff。
- AI生成的代码必须运行测试。
- 不能解释的代码当天不得保留。

建议每天学习2.5~3小时:

环节 时间
理论与流程图 30分钟
亲手输入核心代码 45分钟
AI协作补全与审查 35分钟
测试和故障练习 40分钟
复盘与面试表达 20分钟

第一周:重新搭建一个可测试的原生Agent

第1天:创建标准Python项目与配置系统

今天必须理解

  1. Python包和普通脚本目录的区别。
  2. 为什么配置不能散落在业务代码中。
  3. .env、系统环境变量和默认值的优先级。
  4. 为什么密钥不能提交到 Git。

第一步:创建目录

在项目根目录执行:

bash 复制代码
mkdir -p app tests data/runtime evals notes
touch app/__init__.py
touch tests/__init__.py

最终目录:

text 复制代码
项目根目录/
├── app/
│   └── __init__.py
├── data/
│   └── runtime/
├── evals/
├── notes/
└── tests/
    └── __init__.py

第二步:创建依赖文件

新建requirements.txt

text 复制代码
litellm
python-dotenv
pydantic
pydantic-settings
fastapi
uvicorn
httpx
pytest
pytest-asyncio
langgraph
redis

安装:

bash 复制代码
pip install -r requirements.txt

第三步:创建配置模型

新建app/config.py

python 复制代码
from functools import lru_cache

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    """统一读取环境变量,启动时完成类型校验。"""

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

    llm_api_key: str = Field(alias="LLM_API_KEY")
    llm_base_url: str = Field(alias="LLM_BASE_URL")
    model_name: str = Field(alias="MODEL_NAME")
    request_timeout: float = Field(default=10.0, gt=0)
    max_tool_rounds: int = Field(default=4, ge=1, le=10)


@lru_cache
def get_settings() -> Settings:
    """进程内只构造一次配置,避免每次请求重复读取.env。"""
    return Settings()

在项目根目录.env确认存在:

dotenv 复制代码
LLM_API_KEY=你的密钥
LLM_BASE_URL=你的模型地址
MODEL_NAME=deepseek/deepseek-chat

第四步:写第一个配置测试

新建tests/test_config.py

python 复制代码
from app.config import Settings


def test_settings_can_read_explicit_values():
    settings = Settings(
        LLM_API_KEY="test-key",
        LLM_BASE_URL="https://example.com",
        MODEL_NAME="test-model",
    )

    assert settings.llm_api_key == "test-key"
    assert settings.request_timeout == 10.0
    assert settings.max_tool_rounds == 4

执行:

bash 复制代码
pytest tests/test_config.py -q

预期:

text 复制代码
1 passed

故障练习

把测试中的request_timeout设置为-1

python 复制代码
Settings(
    LLM_API_KEY="x",
    LLM_BASE_URL="https://example.com",
    MODEL_NAME="x",
    request_timeout=-1,
)

观察 Pydantic为什么在程序启动前就拒绝错误配置。

AI Coding提示词

text 复制代码
请只审查app/config.py,不要修改。
检查环境变量别名、默认值、类型约束和密钥泄露风险。
请给出3个应该补充的测试场景。

今日验收

  • 配置测试通过。
  • 删除LLM_API_KEY时能看懂缺失字段错误。
  • 能解释为什么使用lru_cache
  • .env已加入.gitignore

第2天:定义Agent数据契约

今天必须理解

  1. LLM输出合法 JSON不代表业务合法。
  2. TypedDict主要帮助静态检查,Pydantic会执行运行时校验。
  3. 规划结果、工具决策和 API响应不能共用一个模型。
  4. action="none"时为什么必须有最终答案。

第一步:定义工具决策模型

新建app/models.py

python 复制代码
from typing import Any, Literal, TypedDict

from pydantic import BaseModel, ConfigDict, Field, model_validator


ToolName = Literal["calculator", "read_local_file", "search_knowledge", "none"]


class ToolDecision(BaseModel):
    """模型每轮决策必须遵循的结构。"""

    model_config = ConfigDict(extra="forbid")

    thought: str = Field(min_length=1, max_length=200)
    action: ToolName
    params: dict[str, Any]
    answer: str | None

    @model_validator(mode="after")
    def validate_action_and_answer(self) -> "ToolDecision":
        if self.action == "none":
            if self.params:
                raise ValueError("action=none时params必须为空")
            if not self.answer:
                raise ValueError("action=none时必须提供answer")
        elif self.answer is not None:
            raise ValueError("调用工具时answer必须为null")
        return self


class PlanTask(BaseModel):
    task_id: int = Field(ge=1)
    description: str = Field(min_length=1, max_length=300)
    depends_on: list[int] = Field(default_factory=list)


class PlanResponse(BaseModel):
    model_config = ConfigDict(extra="forbid")

    plan: str = Field(min_length=1, max_length=500)
    tasks: list[PlanTask] = Field(min_length=1, max_length=8)


class AgentState(TypedDict):
    """LangGraph节点之间共享的最小状态。"""

    user_query: str
    messages: list[dict[str, str]]
    decision: dict[str, Any] | None
    observations: list[dict[str, Any]]
    tool_round: int
    final_answer: str
    error: str

第二步:测试业务约束

新建tests/test_models.py

python 复制代码
import pytest
from pydantic import ValidationError

from app.models import PlanResponse, ToolDecision


def test_none_action_requires_answer():
    with pytest.raises(ValidationError):
        ToolDecision(
            thought="无需工具",
            action="none",
            params={},
            answer=None,
        )


def test_tool_action_forbids_early_answer():
    with pytest.raises(ValidationError):
        ToolDecision(
            thought="需要计算",
            action="calculator",
            params={"num1": 1, "num2": 2, "operation": "add"},
            answer="3",
        )


def test_plan_has_independent_schema():
    plan = PlanResponse.model_validate(
        {
            "plan": "先计算再汇总",
            "tasks": [
                {
                    "task_id": 1,
                    "description": "计算1+2",
                    "depends_on": [],
                }
            ],
        }
    )

    assert plan.tasks[0].task_id == 1

执行:

bash 复制代码
pytest tests/test_models.py -q

预期:

text 复制代码
3 passed

第三步:观察自动生成的JSON Schema

执行:

bash 复制代码
python - <<'PY'
import json
from app.models import ToolDecision

print(json.dumps(
    ToolDecision.model_json_schema(),
    ensure_ascii=False,
    indent=2,
))
PY

故障练习

分别测试:

python 复制代码
# 多余字段
{"action": "none", "params": {}, "answer": "完成", "reasoning": "秘密推理"}

# 虚构工具
{"action": "delete_database", "params": {}, "answer": None}

# none却携带参数
{"action": "none", "params": {"x": 1}, "answer": "完成"}

AI Coding提示词

text 复制代码
基于app/models.py生成参数化pytest测试。
只测试非法边界,不要修改模型。
覆盖多余字段、虚构工具、空thought和none携带params。

今日验收

  • 能解释三个模型为什么不能合并。
  • 至少覆盖五种非法模型输出。
  • 能从 JSON Schema中找到requiredenum

第3天:实现统一LLM客户端

今天必须理解

  1. LiteLLM负责统一不同厂商的调用格式,不负责你的业务兜底。
  2. 超时、鉴权、限流和空响应必须有不同错误类型。
  3. 模型错误应该向上抛出领域异常,不要伪装成正常答案。
  4. 流式响应需要逐块过滤空内容。

第一步:实现同步与异步客户端

新建app/llm_gateway.py

python 复制代码
from collections.abc import AsyncIterator
from typing import Any

from litellm import (
    AuthenticationError,
    RateLimitError,
    Timeout,
    acompletion,
    completion,
)

from app.config import get_settings


class LLMGatewayError(RuntimeError):
    """所有模型网关业务异常的父类。"""


class LLMTimeoutError(LLMGatewayError):
    pass


class LLMAuthError(LLMGatewayError):
    pass


class LLMRateLimitError(LLMGatewayError):
    pass


class LLMEmptyResponseError(LLMGatewayError):
    pass


def _request_kwargs(messages: list[dict[str, str]]) -> dict[str, Any]:
    settings = get_settings()
    return {
        "model": settings.model_name,
        "messages": messages,
        "api_key": settings.llm_api_key,
        "api_base": settings.llm_base_url,
        "timeout": settings.request_timeout,
        "temperature": 0.1,
    }


def ask_sync(messages: list[dict[str, str]]) -> str:
    """同步获得完整模型文本,错误通过明确异常交给上层处理。"""
    try:
        response = completion(**_request_kwargs(messages))
        content = response.choices[0].message.content
        if not isinstance(content, str) or not content.strip():
            raise LLMEmptyResponseError("模型返回空文本")
        return content.strip()
    except Timeout as exc:
        raise LLMTimeoutError("模型请求超时") from exc
    except AuthenticationError as exc:
        raise LLMAuthError("模型密钥或权限错误") from exc
    except RateLimitError as exc:
        raise LLMRateLimitError("模型限流或额度不足") from exc


async def ask_stream(
    messages: list[dict[str, str]],
) -> AsyncIterator[str]:
    """异步流式返回非空文本片段。"""
    try:
        stream = await acompletion(
            **_request_kwargs(messages),
            stream=True,
        )
        emitted = False

        async for chunk in stream:
            content = chunk.choices[0].delta.content
            if isinstance(content, str) and content:
                emitted = True
                yield content

        if not emitted:
            raise LLMEmptyResponseError("模型流式响应为空")
    except Timeout as exc:
        raise LLMTimeoutError("模型流式请求超时") from exc
    except AuthenticationError as exc:
        raise LLMAuthError("模型密钥或权限错误") from exc
    except RateLimitError as exc:
        raise LLMRateLimitError("模型限流或额度不足") from exc

第二步:写最小手工测试

新建manual_test_llm.py

python 复制代码
import asyncio

from app.llm_gateway import ask_stream, ask_sync


def test_sync() -> None:
    answer = ask_sync([{"role": "user", "content": "只回答:同步成功"}])
    print("同步回答:", answer)


async def test_stream() -> None:
    print("流式回答:", end="", flush=True)
    async for text in ask_stream(
        [{"role": "user", "content": "用一句话解释AI Agent"}]
    ):
        print(text, end="", flush=True)
    print()


if __name__ == "__main__":
    test_sync()
    asyncio.run(test_stream())

执行:

bash 复制代码
python manual_test_llm.py

第三步:Mock空响应

新建tests/test_llm_gateway.py

python 复制代码
from types import SimpleNamespace

import pytest

from app import llm_gateway


def test_sync_rejects_empty_response(monkeypatch):
    fake_response = SimpleNamespace(
        choices=[
            SimpleNamespace(
                message=SimpleNamespace(content="   "),
            )
        ]
    )
    monkeypatch.setattr(
        llm_gateway,
        "completion",
        lambda **kwargs: fake_response,
    )

    with pytest.raises(llm_gateway.LLMEmptyResponseError):
        llm_gateway.ask_sync(
            [{"role": "user", "content": "hello"}]
        )

故障练习

临时使用错误密钥,观察LLMAuthError;然后恢复正确密钥。不要把错误密钥提交。

AI Coding提示词

text 复制代码
审查llm_gateway.py的异常边界。
不要修改代码,告诉我哪些LiteLLM异常没有被转换,
哪些异常应该重试,哪些绝对不应该重试。

今日验收

  • 同步和流式调用成功。
  • 空响应测试不访问真实模型。
  • 能解释为什么不返回"请求失败"字符串。

第4天:工具注册、Schema与参数校验

今天必须理解

  1. 工具的代码实现和给模型看的 Schema是两个层面。
  2. 工具名必须稳定、唯一。
  3. 参数必须在执行前完成类型和业务校验。
  4. 工具结果应该是结构化数据,而不只是自然语言。

第一步:创建工具注册表

新建app/tools.py

python 复制代码
from collections.abc import Callable
from pathlib import Path
from typing import Any

from pydantic import BaseModel, Field


class CalculatorArgs(BaseModel):
    num1: float
    num2: float
    operation: str = Field(pattern="^(add|sub|mul|div)$")


class ReadFileArgs(BaseModel):
    file_path: str = Field(min_length=1, max_length=200)


class ToolSpec:
    def __init__(
        self,
        name: str,
        description: str,
        args_model: type[BaseModel],
        handler: Callable[..., dict[str, Any]],
    ) -> None:
        self.name = name
        self.description = description
        self.args_model = args_model
        self.handler = handler

    def schema(self) -> dict[str, Any]:
        return {
            "name": self.name,
            "description": self.description,
            "parameters": self.args_model.model_json_schema(),
        }

    def invoke(self, params: dict[str, Any]) -> dict[str, Any]:
        validated = self.args_model.model_validate(params)
        return self.handler(**validated.model_dump())


def calculator(
    num1: float,
    num2: float,
    operation: str,
) -> dict[str, Any]:
    if operation == "add":
        value = num1 + num2
    elif operation == "sub":
        value = num1 - num2
    elif operation == "mul":
        value = num1 * num2
    elif operation == "div":
        if num2 == 0:
            return {"ok": False, "error": "除数不能为0"}
        value = num1 / num2
    else:
        return {"ok": False, "error": f"不支持操作:{operation}"}
    return {"ok": True, "value": value}


def read_local_file(file_path: str) -> dict[str, Any]:
    root = Path.cwd().resolve()
    target = (root / file_path).resolve()

    # commonpath比简单startswith更可靠,防止读取项目目录之外的文件
    if root not in target.parents and target != root:
        return {"ok": False, "error": "禁止访问项目目录之外的文件"}
    if target.suffix.lower() not in {".txt", ".md"}:
        return {"ok": False, "error": "仅允许读取.txt和.md"}
    if not target.is_file():
        return {"ok": False, "error": "文件不存在"}
    return {
        "ok": True,
        "content": target.read_text(encoding="utf-8"),
    }


TOOL_REGISTRY = {
    "calculator": ToolSpec(
        name="calculator",
        description="执行加减乘除",
        args_model=CalculatorArgs,
        handler=calculator,
    ),
    "read_local_file": ToolSpec(
        name="read_local_file",
        description="读取项目目录内的txt或md文件",
        args_model=ReadFileArgs,
        handler=read_local_file,
    ),
}


def get_tool_schemas() -> list[dict[str, Any]]:
    return [tool.schema() for tool in TOOL_REGISTRY.values()]


def execute_tool(
    name: str,
    params: dict[str, Any],
) -> dict[str, Any]:
    tool = TOOL_REGISTRY.get(name)
    if tool is None:
        return {"ok": False, "error": f"工具不存在:{name}"}
    return tool.invoke(params)

第二步:测试工具

新建tests/test_tools.py

python 复制代码
from app.tools import execute_tool, get_tool_schemas


def test_calculator_multiply():
    result = execute_tool(
        "calculator",
        {"num1": 125, "num2": 8, "operation": "mul"},
    )
    assert result == {"ok": True, "value": 1000.0}


def test_calculator_rejects_zero_division():
    result = execute_tool(
        "calculator",
        {"num1": 100, "num2": 0, "operation": "div"},
    )
    assert result["ok"] is False


def test_schema_contains_required_fields():
    calculator_schema = get_tool_schemas()[0]
    assert calculator_schema["name"] == "calculator"
    assert "parameters" in calculator_schema
    assert "required" in calculator_schema["parameters"]

执行:

bash 复制代码
pytest tests/test_tools.py -q

故障练习

执行:

python 复制代码
execute_tool("read_local_file", {"file_path": "../../.env"})
execute_tool("calculator", {"num1": "abc", "num2": 1, "operation": "add"})
execute_tool("delete_all", {})

观察三种错误分别发生在哪一层。

AI Coding提示词

text 复制代码
请针对app/tools.py做安全审查。
重点检查目录穿越、参数类型、异常泄露和工具返回一致性。
先报告问题,不要直接修改。

今日验收

  • 工具 Schema由参数模型自动生成。
  • 非法参数不会进入处理函数。
  • ../../.env读取失败。
  • 能解释为什么工具返回ok/value/error

第5天:实现原生工具调用闭环

今天必须理解

完整 Agent循环不是"调用一次模型",而是:

text 复制代码
用户问题
→ 模型决策
→ JSON校验
→ 工具执行
→ Observation回传
→ 再次模型决策
→ action=none结束

第一步:创建提示词

新建app/prompts.py

python 复制代码
import json

from app.models import ToolDecision
from app.tools import get_tool_schemas


def build_agent_prompt(
    user_query: str,
    observations: list[dict],
) -> str:
    schema = ToolDecision.model_json_schema()
    tools = get_tool_schemas()

    return f"""
你是工具调用Agent。请根据用户问题和真实观测决定下一步。

可用工具:
{json.dumps(tools, ensure_ascii=False, indent=2)}

已有真实观测:
{json.dumps(observations, ensure_ascii=False, indent=2)}

用户问题:
{user_query}

只返回符合以下Schema的JSON:
{json.dumps(schema, ensure_ascii=False, indent=2)}

规则:
1. action只能选择可用工具或none。
2. 调用工具时answer必须为null。
3. 信息足够时action必须为none,params为空,并给出answer。
4. 不得虚构工具结果。
5. thought只写简短决策摘要,不输出详细思维链。
""".strip()

第二步:实现循环

新建app/native_agent.py

python 复制代码
from collections.abc import Callable
from typing import Any

from app.config import get_settings
from app.llm_gateway import ask_sync
from app.models import ToolDecision
from app.prompts import build_agent_prompt
from app.tools import execute_tool


LLMFunction = Callable[[list[dict[str, str]]], str]


def run_native_agent(
    user_query: str,
    llm_func: LLMFunction = ask_sync,
) -> dict[str, Any]:
    """原生while循环,实现模型和工具之间的完整闭环。"""
    observations: list[dict[str, Any]] = []
    max_rounds = get_settings().max_tool_rounds

    for round_number in range(1, max_rounds + 1):
        prompt = build_agent_prompt(user_query, observations)
        raw_response = llm_func(
            [{"role": "user", "content": prompt}]
        )

        try:
            decision = ToolDecision.model_validate_json(raw_response)
        except Exception as exc:
            return {
                "ok": False,
                "error": f"模型输出校验失败:{exc}",
                "observations": observations,
            }

        print(
            f"第{round_number}轮:"
            f"action={decision.action}, params={decision.params}"
        )

        if decision.action == "none":
            return {
                "ok": True,
                "answer": decision.answer,
                "observations": observations,
                "rounds": round_number,
            }

        result = execute_tool(decision.action, decision.params)
        observations.append(
            {
                "round": round_number,
                "action": decision.action,
                "params": decision.params,
                "result": result,
            }
        )

    return {
        "ok": False,
        "error": f"达到最大工具调用轮数:{max_rounds}",
        "observations": observations,
    }

第三步:不用真实模型测试循环

新建tests/test_native_agent.py

python 复制代码
import json

from app.native_agent import run_native_agent


def test_agent_calls_tool_then_finishes():
    responses = iter(
        [
            json.dumps(
                {
                    "thought": "需要先计算",
                    "action": "calculator",
                    "params": {
                        "num1": 125,
                        "num2": 8,
                        "operation": "mul",
                    },
                    "answer": None,
                }
            ),
            json.dumps(
                {
                    "thought": "已获得计算结果",
                    "action": "none",
                    "params": {},
                    "answer": "结果是1000",
                }
            ),
        ]
    )

    def fake_llm(messages):
        return next(responses)

    result = run_native_agent(
        "计算125乘以8",
        llm_func=fake_llm,
    )

    assert result["ok"] is True
    assert result["answer"] == "结果是1000"
    assert result["observations"][0]["result"]["value"] == 1000

执行:

bash 复制代码
pytest tests/test_native_agent.py -q -s

第四步:真实模型测试

新建run_native.py

python 复制代码
from pprint import pprint

from app.native_agent import run_native_agent


if __name__ == "__main__":
    pprint(run_native_agent("计算125乘以8,再加360"))

执行:

bash 复制代码
python run_native.py

故障练习

fake_llm返回:

text 复制代码
这不是JSON

确认 Agent返回格式错误,而不是执行任何工具。

再让模型连续四轮调用相同工具,确认最大轮数终止。

AI Coding提示词

text 复制代码
请只审查native_agent.py的循环终止条件。
列出可能导致死循环、重复工具调用和错误结果回传的场景。
不要直接修改,先给出对应测试。

今日验收

  • Fake LLM测试通过。
  • 真实模型能完成至少一次工具调用。
  • 非JSON输出不会触发工具。
  • 最大轮数能够终止循环。

第6天:异步并发、超时与单元测试

今天必须理解

  1. async def只表示函数可以被异步调度,不代表内部同步代码自动变快。
  2. 独立的 I/O任务可以并发,有前后依赖的工具不能并发。
  3. 总任务超时和单次模型超时是两层保护。
  4. Agent测试应注入 Fake LLM,避免每次测试花费 Token。

第一步:实现并行工具执行器

新建app/async_tools.py

python 复制代码
import asyncio
from typing import Any

from app.tools import execute_tool


async def execute_one_async(
    name: str,
    params: dict[str, Any],
    timeout: float = 5.0,
) -> dict[str, Any]:
    """把同步工具放到工作线程,并设置单工具超时。"""
    try:
        async with asyncio.timeout(timeout):
            return await asyncio.to_thread(
                execute_tool,
                name,
                params,
            )
    except TimeoutError:
        return {
            "ok": False,
            "error": f"工具{name}执行超过{timeout}秒",
        }


async def execute_parallel(
    calls: list[dict[str, Any]],
    max_concurrency: int = 3,
) -> list[dict[str, Any]]:
    """并发执行互不依赖的工具,并限制最大并发。"""
    semaphore = asyncio.Semaphore(max_concurrency)

    async def guarded(call: dict[str, Any]) -> dict[str, Any]:
        async with semaphore:
            result = await execute_one_async(
                call["name"],
                call["params"],
            )
            return {
                "call_id": call["call_id"],
                "name": call["name"],
                "result": result,
            }

    return await asyncio.gather(
        *(guarded(call) for call in calls)
    )

第二步:写异步测试

新建tests/test_async_tools.py

python 复制代码
import pytest

from app.async_tools import execute_parallel


@pytest.mark.asyncio
async def test_execute_parallel_keeps_call_ids():
    calls = [
        {
            "call_id": "a",
            "name": "calculator",
            "params": {
                "num1": 2,
                "num2": 3,
                "operation": "mul",
            },
        },
        {
            "call_id": "b",
            "name": "calculator",
            "params": {
                "num1": 10,
                "num2": 4,
                "operation": "add",
            },
        },
    ]

    results = await execute_parallel(calls)

    assert [item["call_id"] for item in results] == ["a", "b"]
    assert results[0]["result"]["value"] == 6
    assert results[1]["result"]["value"] == 14

执行:

bash 复制代码
pytest tests/test_async_tools.py -q

第三步:做并发耗时实验

新建async_timing_demo.py

python 复制代码
import asyncio
import time


async def fake_io(name: str, seconds: float) -> str:
    await asyncio.sleep(seconds)
    return name


async def main() -> None:
    start = time.perf_counter()
    await fake_io("a", 1)
    await fake_io("b", 1)
    print(f"串行耗时:{time.perf_counter() - start:.2f}s")

    start = time.perf_counter()
    await asyncio.gather(
        fake_io("a", 1),
        fake_io("b", 1),
    )
    print(f"并行耗时:{time.perf_counter() - start:.2f}s")


if __name__ == "__main__":
    asyncio.run(main())

执行后预期:

text 复制代码
串行耗时:约2.00s
并行耗时:约1.00s

故障练习

fake_io中的await asyncio.sleep()改成time.sleep(),观察为什么两个任务又变成约2秒。

AI Coding提示词

text 复制代码
请解释async_tools.py中Semaphore、to_thread、timeout和gather各自解决什么问题。
然后生成一个"工具超时但其他工具仍正常返回"的pytest,不修改业务代码。

今日验收

  • 异步测试通过。
  • 能解释同步阻塞和异步等待的区别。
  • 知道哪些工具可以并行,哪些有依赖不能并行。

第7天:把Agent封装成可测试的FastAPI服务

今天必须理解

  1. Web服务启动成功不代表业务接口成功。
  2. Pydantic请求模型负责入口校验。
  3. HTTPException必须raise
  4. 同步 Agent放在异步接口中会阻塞事件循环,需要放入工作线程。

第一步:创建API服务

新建app/api.py

python 复制代码
import asyncio
import uuid
from typing import Any

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, ConfigDict, Field

from app.native_agent import run_native_agent


app = FastAPI(
    title="Training Agent API",
    version="0.1.0",
)


class ChatRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")

    user_query: str = Field(min_length=1, max_length=2000)
    thread_id: str | None = Field(default=None, max_length=100)


class ChatData(BaseModel):
    request_id: str
    thread_id: str
    answer: str
    observations: list[dict[str, Any]]


class ChatResponse(BaseModel):
    code: int
    message: str
    data: ChatData


@app.get("/")
async def root() -> dict[str, str]:
    return {
        "service": "training-agent",
        "docs": "/docs",
    }


@app.get("/health")
async def health() -> dict[str, str]:
    return {"status": "ok"}


@app.post("/agent/chat", response_model=ChatResponse)
async def chat(request: ChatRequest) -> ChatResponse:
    request_id = str(uuid.uuid4())
    thread_id = request.thread_id or str(uuid.uuid4())

    # 原生Agent是同步函数,放到线程中避免阻塞FastAPI事件循环
    result = await asyncio.to_thread(
        run_native_agent,
        request.user_query,
    )

    if not result["ok"]:
        raise HTTPException(
            status_code=502,
            detail={
                "request_id": request_id,
                "error": result["error"],
            },
        )

    return ChatResponse(
        code=200,
        message="success",
        data=ChatData(
            request_id=request_id,
            thread_id=thread_id,
            answer=result["answer"],
            observations=result["observations"],
        ),
    )

第二步:创建启动文件

新建server.py

python 复制代码
import uvicorn


if __name__ == "__main__":
    uvicorn.run(
        "app.api:app",
        host="127.0.0.1",
        port=8010,
        reload=True,
    )

执行:

bash 复制代码
python server.py

访问:

text 复制代码
http://127.0.0.1:8010/docs
http://127.0.0.1:8010/health

第三步:测试API但不访问真实模型

新建tests/test_api.py

python 复制代码
from fastapi.testclient import TestClient

from app import api


client = TestClient(api.app)


def test_health():
    response = client.get("/health")
    assert response.status_code == 200
    assert response.json() == {"status": "ok"}


def test_chat_rejects_empty_query():
    response = client.post(
        "/agent/chat",
        json={"user_query": ""},
    )
    assert response.status_code == 422


def test_chat_success(monkeypatch):
    def fake_agent(query):
        return {
            "ok": True,
            "answer": "模拟答案",
            "observations": [],
        }

    monkeypatch.setattr(api, "run_native_agent", fake_agent)

    response = client.post(
        "/agent/chat",
        json={"user_query": "你好", "thread_id": "thread-1"},
    )

    assert response.status_code == 200
    assert response.json()["data"]["answer"] == "模拟答案"
    assert response.json()["data"]["thread_id"] == "thread-1"

执行:

bash 复制代码
pytest tests/test_api.py -q

故障练习

把:

python 复制代码
result = await asyncio.to_thread(run_native_agent, request.user_query)

临时改为:

python 复制代码
result = run_native_agent(request.user_query)

思考:单用户测试为什么仍可能成功?高并发时为什么会阻塞?实验后恢复。

AI Coding提示词

text 复制代码
先读api.py和test_api.py。
只补充502失败响应测试,并检查是否会调用真实模型。
不要重构现有接口。

第一周验收

执行:

bash 复制代码
pytest tests -q

你必须能完成:

  • 不看代码画出API到工具的调用链。
  • Fake LLM测试不消耗Token。
  • Swagger可以调用真实Agent。
  • 空问题返回422。
  • 模型失败返回502而不是伪装200。

第二周:构建一个可评测的RAG知识工具

第8天:文档加载、清洗与元数据

今天必须理解

  1. RAG首先是数据工程,不是先选向量数据库。
  2. 每段文本必须保留来源,否则最终无法引用。
  3. 文档更新需要稳定 ID和内容哈希。
  4. 空文档、重复文档和编码错误必须显式处理。

第一步:创建文档模型和加载器

新建app/documents.py

python 复制代码
import hashlib
from pathlib import Path

from pydantic import BaseModel, Field


class Document(BaseModel):
    document_id: str
    source: str
    title: str
    content: str = Field(min_length=1)
    content_hash: str


def normalize_text(text: str) -> str:
    """保留段落,只清理行尾空格和过多空行。"""
    lines = [line.rstrip() for line in text.splitlines()]
    cleaned: list[str] = []
    previous_blank = False

    for line in lines:
        is_blank = not line.strip()
        if is_blank and previous_blank:
            continue
        cleaned.append(line)
        previous_blank = is_blank

    return "\n".join(cleaned).strip()


def load_text_document(file_path: str) -> Document:
    path = Path(file_path).resolve()

    if path.suffix.lower() not in {".txt", ".md"}:
        raise ValueError("目前只支持.txt和.md")
    if not path.is_file():
        raise FileNotFoundError(path)

    raw_text = path.read_text(encoding="utf-8")
    content = normalize_text(raw_text)
    if not content:
        raise ValueError("文档清洗后为空")

    digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
    return Document(
        document_id=digest[:16],
        source=str(path),
        title=path.stem,
        content=content,
        content_hash=digest,
    )

第二步:准备测试文档

新建data/agent_basics.md

markdown 复制代码
# AI Agent基础

AI Agent通常包含模型、工具、状态和控制流程。

工具调用必须经过参数校验。高风险写操作应该增加人工审批。

LangGraph可以使用State、Node和Edge组织有状态工作流。

第三步:测试加载器

新建tests/test_documents.py

python 复制代码
from pathlib import Path

import pytest

from app.documents import (
    load_text_document,
    normalize_text,
)


def test_normalize_removes_repeated_blank_lines():
    assert normalize_text("a\n\n\nb") == "a\n\nb"


def test_load_document_has_stable_id():
    path = "data/agent_basics.md"
    first = load_text_document(path)
    second = load_text_document(path)

    assert first.document_id == second.document_id
    assert first.title == "agent_basics"


def test_empty_document_is_rejected(tmp_path: Path):
    path = tmp_path / "empty.md"
    path.write_text("\n\n", encoding="utf-8")

    with pytest.raises(ValueError, match="为空"):
        load_text_document(str(path))

执行:

bash 复制代码
pytest tests/test_documents.py -q

故障练习

创建一个 GBK编码文件,用 UTF-8读取,观察UnicodeDecodeError。思考应该拒绝、自动探测还是要求用户指定编码。

AI Coding提示词

text 复制代码
为documents.py设计重复文档、文件更新和删除策略。
先输出数据流和测试,不要直接引入向量数据库。

今日验收

  • 每份文档有稳定ID、来源和哈希。
  • 空文档被拒绝。
  • 能解释清洗为什么不能破坏标题和段落。

第9天:Chunk切分与Embedding接口

今天必须理解

  1. Chunk太大导致检索不精确,太小会丢失上下文。
  2. overlap可以缓解边界切断,但会增加索引量和重复结果。
  3. Embedding模型必须与查询使用同一模型。
  4. 业务代码不应该绑定某一家Embedding供应商。

第一步:实现按段落切分

新建app/chunking.py

python 复制代码
import hashlib

from pydantic import BaseModel

from app.documents import Document


class Chunk(BaseModel):
    chunk_id: str
    document_id: str
    source: str
    position: int
    text: str


def split_document(
    document: Document,
    max_chars: int = 300,
) -> list[Chunk]:
    """优先按空行分段,再组合到接近max_chars。"""
    paragraphs = [
        paragraph.strip()
        for paragraph in document.content.split("\n\n")
        if paragraph.strip()
    ]
    groups: list[str] = []
    current = ""

    for paragraph in paragraphs:
        candidate = (
            paragraph
            if not current
            else f"{current}\n\n{paragraph}"
        )
        if len(candidate) <= max_chars:
            current = candidate
            continue
        if current:
            groups.append(current)
        current = paragraph

    if current:
        groups.append(current)

    chunks: list[Chunk] = []
    for position, text in enumerate(groups):
        raw_id = f"{document.document_id}:{position}:{text}"
        chunk_id = hashlib.sha256(
            raw_id.encode("utf-8")
        ).hexdigest()[:20]
        chunks.append(
            Chunk(
                chunk_id=chunk_id,
                document_id=document.document_id,
                source=document.source,
                position=position,
                text=text,
            )
        )
    return chunks

第二步:定义Embedding协议和本地测试实现

新建app/embeddings.py

python 复制代码
import math
from collections import Counter
from collections.abc import Sequence
from typing import Protocol


class EmbeddingProvider(Protocol):
    def embed(self, texts: Sequence[str]) -> list[list[float]]:
        """把多个文本转换成等长向量。"""


class TinyHashEmbedding:
    """仅用于学习和单元测试,不用于真实语义检索。"""

    def __init__(self, dimensions: int = 64) -> None:
        self.dimensions = dimensions

    def embed(self, texts: Sequence[str]) -> list[list[float]]:
        vectors: list[list[float]] = []

        for text in texts:
            counts = Counter(text.lower())
            vector = [0.0] * self.dimensions
            for token, count in counts.items():
                index = hash(token) % self.dimensions
                vector[index] += float(count)

            norm = math.sqrt(sum(value * value for value in vector))
            if norm:
                vector = [value / norm for value in vector]
            vectors.append(vector)

        return vectors

第三步:测试切分

新建tests/test_chunking.py

python 复制代码
from app.chunking import split_document
from app.documents import load_text_document
from app.embeddings import TinyHashEmbedding


def test_split_document_preserves_source():
    document = load_text_document(
        "data/agent_basics.md"
    )
    chunks = split_document(document, max_chars=80)

    assert len(chunks) >= 2
    assert all(chunk.source == document.source for chunk in chunks)
    assert [chunk.position for chunk in chunks] == list(range(len(chunks)))


def test_embedding_vectors_have_same_dimension():
    provider = TinyHashEmbedding(dimensions=16)
    vectors = provider.embed(["Agent工具", "LangGraph状态"])

    assert len(vectors) == 2
    assert all(len(vector) == 16 for vector in vectors)

故障练习

分别使用max_chars=30、100、1000打印 Chunk,观察完整性和数量:

bash 复制代码
python - <<'PY'
from app.documents import load_text_document
from app.chunking import split_document

doc = load_text_document("data/agent_basics.md")
for size in (30, 100, 1000):
    chunks = split_document(doc, max_chars=size)
    print(size, len(chunks), [c.text for c in chunks])
PY

AI Coding提示词

text 复制代码
分析chunking.py在"单个段落超过max_chars"时的缺陷。
先写失败测试,再给最小修复方案,不要引入复杂框架。

今日验收

  • Chunk保留来源和位置。
  • 相同内容得到稳定Chunk ID。
  • 能解释测试Embedding为什么不能用于生产。

第10天:实现最小向量索引与检索

今天必须理解

  1. 索引阶段保存 Chunk向量,查询阶段只计算问题向量。
  2. 余弦相似度衡量方向相似,不等于事实正确。
  3. Top-K和阈值控制候选范围。
  4. 检索层只返回证据,不负责生成最终答案。

第一步:实现内存向量索引

新建app/vector_store.py

python 复制代码
from dataclasses import dataclass

from app.chunking import Chunk
from app.embeddings import EmbeddingProvider


@dataclass
class SearchResult:
    chunk: Chunk
    score: float


def dot_product(left: list[float], right: list[float]) -> float:
    if len(left) != len(right):
        raise ValueError("向量维度不一致")
    return sum(a * b for a, b in zip(left, right, strict=True))


class InMemoryVectorStore:
    def __init__(self, embedding: EmbeddingProvider) -> None:
        self.embedding = embedding
        self._items: list[tuple[Chunk, list[float]]] = []

    def add(self, chunks: list[Chunk]) -> None:
        vectors = self.embedding.embed(
            [chunk.text for chunk in chunks]
        )
        self._items.extend(zip(chunks, vectors, strict=True))

    def search(
        self,
        query: str,
        top_k: int = 3,
        min_score: float = 0.0,
    ) -> list[SearchResult]:
        query_vector = self.embedding.embed([query])[0]
        results = [
            SearchResult(
                chunk=chunk,
                score=dot_product(query_vector, vector),
            )
            for chunk, vector in self._items
        ]
        results.sort(key=lambda item: item.score, reverse=True)
        return [
            item
            for item in results[:top_k]
            if item.score >= min_score
        ]

第二步:创建索引构建函数

新建app/knowledge_base.py

python 复制代码
from app.chunking import split_document
from app.documents import load_text_document
from app.embeddings import TinyHashEmbedding
from app.vector_store import InMemoryVectorStore


def build_demo_knowledge_base() -> InMemoryVectorStore:
    document = load_text_document(
        "data/agent_basics.md"
    )
    chunks = split_document(document, max_chars=100)
    store = InMemoryVectorStore(TinyHashEmbedding())
    store.add(chunks)
    return store

第三步:写检索测试

新建tests/test_vector_store.py

python 复制代码
from app.knowledge_base import (
    build_demo_knowledge_base,
)


def test_search_returns_source_and_score():
    store = build_demo_knowledge_base()
    results = store.search("工具参数为什么要校验", top_k=2)

    assert results
    assert results[0].chunk.source.endswith("agent_basics.md")
    assert isinstance(results[0].score, float)


def test_top_k_limits_results():
    store = build_demo_knowledge_base()
    results = store.search("Agent", top_k=1)
    assert len(results) == 1

第四步:打印真实检索结果

执行:

bash 复制代码
python - <<'PY'
from app.knowledge_base import build_demo_knowledge_base

store = build_demo_knowledge_base()
for item in store.search("LangGraph如何组织工作流", top_k=3):
    print(f"score={item.score:.4f}")
    print(item.chunk.text)
    print("-" * 30)
PY

故障练习

查询"明天上海天气",观察本地简化Embedding仍可能返回不相关 Chunk。记录:

  • 为什么 Top-K一定会返回结果?
  • 为什么需要阈值和"资料不足"判断?
  • 为什么真实项目需要语义Embedding?

AI Coding提示词

text 复制代码
请审查vector_store.py。
为"空索引、top_k为0、向量维度错误、低于阈值"生成测试,
不要修改检索算法。

第10天验收

  • 能独立构建索引并查询。
  • 检索结果保留来源、位置和分数。
  • 能解释为什么召回第一名也可能是错的。

第11天:关键词检索、混合检索与RRF

今天必须理解

  1. 向量检索擅长语义,关键词检索擅长编号、专有名词和精确文本。
  2. 混合检索不是把两个分数直接相加,因为分数量纲不同。
  3. RRF使用排名而不是原始分数融合结果。
  4. 中文分词质量会直接影响 BM25。

第一步:安装BM25依赖

bash 复制代码
pip install rank-bm25

并把下面一行加入requirements.txt

text 复制代码
rank-bm25

第二步:实现关键词索引

新建app/hybrid_search.py

python 复制代码
from dataclasses import dataclass

from rank_bm25 import BM25Okapi

from app.chunking import Chunk
from app.vector_store import InMemoryVectorStore


def tokenize(text: str) -> list[str]:
    """教学版中文按字符切分;生产环境应替换为可靠分词器。"""
    return [
        character.lower()
        for character in text
        if not character.isspace()
    ]


@dataclass
class HybridResult:
    chunk: Chunk
    rrf_score: float
    vector_rank: int | None
    keyword_rank: int | None


class KeywordStore:
    def __init__(self, chunks: list[Chunk]) -> None:
        self.chunks = chunks
        corpus = [tokenize(chunk.text) for chunk in chunks]
        self.bm25 = BM25Okapi(corpus)

    def search(self, query: str, top_k: int = 5) -> list[Chunk]:
        scores = self.bm25.get_scores(tokenize(query))
        ranked = sorted(
            zip(self.chunks, scores, strict=True),
            key=lambda item: item[1],
            reverse=True,
        )
        return [chunk for chunk, _ in ranked[:top_k]]


def reciprocal_rank_fusion(
    vector_chunks: list[Chunk],
    keyword_chunks: list[Chunk],
    top_k: int = 5,
    rank_constant: int = 60,
) -> list[HybridResult]:
    """使用排名融合,避免直接相加不同检索器的原始分数。"""
    score_by_id: dict[str, float] = {}
    chunk_by_id: dict[str, Chunk] = {}
    vector_rank: dict[str, int] = {}
    keyword_rank: dict[str, int] = {}

    for rank, chunk in enumerate(vector_chunks, start=1):
        chunk_by_id[chunk.chunk_id] = chunk
        vector_rank[chunk.chunk_id] = rank
        score_by_id[chunk.chunk_id] = (
            score_by_id.get(chunk.chunk_id, 0.0)
            + 1 / (rank_constant + rank)
        )

    for rank, chunk in enumerate(keyword_chunks, start=1):
        chunk_by_id[chunk.chunk_id] = chunk
        keyword_rank[chunk.chunk_id] = rank
        score_by_id[chunk.chunk_id] = (
            score_by_id.get(chunk.chunk_id, 0.0)
            + 1 / (rank_constant + rank)
        )

    ranked_ids = sorted(
        score_by_id,
        key=score_by_id.get,
        reverse=True,
    )
    return [
        HybridResult(
            chunk=chunk_by_id[chunk_id],
            rrf_score=score_by_id[chunk_id],
            vector_rank=vector_rank.get(chunk_id),
            keyword_rank=keyword_rank.get(chunk_id),
        )
        for chunk_id in ranked_ids[:top_k]
    ]


def hybrid_search(
    query: str,
    vector_store: InMemoryVectorStore,
    keyword_store: KeywordStore,
    top_k: int = 3,
) -> list[HybridResult]:
    vector_chunks = [
        result.chunk
        for result in vector_store.search(query, top_k=top_k * 2)
    ]
    keyword_chunks = keyword_store.search(query, top_k=top_k * 2)
    return reciprocal_rank_fusion(
        vector_chunks,
        keyword_chunks,
        top_k=top_k,
    )

第三步:编写融合测试

新建tests/test_hybrid_search.py

python 复制代码
from app.chunking import split_document
from app.documents import load_text_document
from app.embeddings import TinyHashEmbedding
from app.hybrid_search import (
    KeywordStore,
    hybrid_search,
)
from app.vector_store import InMemoryVectorStore


def build_stores():
    document = load_text_document(
        "data/agent_basics.md"
    )
    chunks = split_document(document, max_chars=80)
    vector_store = InMemoryVectorStore(TinyHashEmbedding())
    vector_store.add(chunks)
    return vector_store, KeywordStore(chunks)


def test_hybrid_result_records_both_ranks():
    vector_store, keyword_store = build_stores()
    results = hybrid_search(
        "LangGraph State Node Edge",
        vector_store,
        keyword_store,
    )

    assert results
    assert results[0].rrf_score > 0
    assert (
        results[0].vector_rank is not None
        or results[0].keyword_rank is not None
    )

故障练习

在测试文档加入产品编号AGENT-XF-2026,分别查询:

text 复制代码
AGENT-XF-2026
2026版Agent产品编号

观察关键词和简化向量检索的差异。

AI Coding提示词

text 复制代码
为hybrid_search.py生成一个对比脚本,
分别输出关键词排名、向量排名和RRF排名。
不要替我下结论,我会根据结果写分析。

今日验收

  • 能解释为什么不能直接相加BM25和余弦分数。
  • 结果记录两种排名,便于调试。
  • 用一个专有名词案例证明混合检索的价值。

第12天:基于证据生成答案与引用

今天必须理解

  1. 检索结果是候选证据,不是最终答案。
  2. 引用必须只能来自实际传入模型的 Chunk。
  3. "资料不足"应该由证据状态决定,而不是一句提示词碰运气。
  4. 最终输出也需要独立的数据模型。

第一步:定义引用回答模型

app/models.py末尾增加:

python 复制代码
class Citation(BaseModel):
    chunk_id: str
    source: str
    quote: str = Field(min_length=1, max_length=300)


class GroundedAnswer(BaseModel):
    model_config = ConfigDict(extra="forbid")

    answer: str = Field(min_length=1)
    citations: list[Citation]
    insufficient_evidence: bool

    @model_validator(mode="after")
    def validate_evidence_state(self) -> "GroundedAnswer":
        if self.insufficient_evidence and self.citations:
            raise ValueError("资料不足时不应伪造引用")
        if not self.insufficient_evidence and not self.citations:
            raise ValueError("有事实答案时必须提供引用")
        return self

第二步:实现证据提示词

新建app/grounded_answer.py

python 复制代码
import json

from app.llm_gateway import ask_sync
from app.models import GroundedAnswer
from app.vector_store import SearchResult


def build_grounded_prompt(
    question: str,
    results: list[SearchResult],
) -> str:
    evidence = [
        {
            "chunk_id": item.chunk.chunk_id,
            "source": item.chunk.source,
            "text": item.chunk.text,
        }
        for item in results
    ]

    return f"""
请只根据给定证据回答问题。

证据:
{json.dumps(evidence, ensure_ascii=False, indent=2)}

问题:
{question}

输出必须符合Schema:
{json.dumps(GroundedAnswer.model_json_schema(), ensure_ascii=False, indent=2)}

规则:
1. 引用的chunk_id和source必须来自上面的证据。
2. quote必须是证据中的短句,不得编造。
3. 证据不足时insufficient_evidence=true,citations为空。
4. 只输出JSON,不要Markdown。
""".strip()


def answer_with_evidence(
    question: str,
    results: list[SearchResult],
) -> GroundedAnswer:
    if not results:
        return GroundedAnswer(
            answer="资料不足,无法根据知识库回答。",
            citations=[],
            insufficient_evidence=True,
        )

    raw = ask_sync(
        [
            {
                "role": "user",
                "content": build_grounded_prompt(question, results),
            }
        ]
    )
    answer = GroundedAnswer.model_validate_json(raw)

    # 代码层再次校验引用ID,不能只相信提示词
    allowed_ids = {item.chunk.chunk_id for item in results}
    invalid_ids = {
        citation.chunk_id
        for citation in answer.citations
        if citation.chunk_id not in allowed_ids
    }
    if invalid_ids:
        raise ValueError(f"模型引用了不存在的证据:{invalid_ids}")
    return answer

第三步:测试无证据和虚构引用

新建tests/test_grounded_answer.py

python 复制代码
import json

import pytest

from app import grounded_answer
from app.chunking import Chunk
from app.vector_store import SearchResult


def test_empty_evidence_returns_insufficient():
    answer = grounded_answer.answer_with_evidence(
        "不存在的问题",
        [],
    )
    assert answer.insufficient_evidence is True
    assert answer.citations == []


def test_fake_citation_is_rejected(monkeypatch):
    result = SearchResult(
        chunk=Chunk(
            chunk_id="real-id",
            document_id="doc-1",
            source="a.md",
            position=0,
            text="工具参数必须校验。",
        ),
        score=0.9,
    )

    fake_output = json.dumps(
        {
            "answer": "需要校验。",
            "citations": [
                {
                    "chunk_id": "fake-id",
                    "source": "x.md",
                    "quote": "虚构证据",
                }
            ],
            "insufficient_evidence": False,
        },
        ensure_ascii=False,
    )
    monkeypatch.setattr(
        grounded_answer,
        "ask_sync",
        lambda messages: fake_output,
    )

    with pytest.raises(ValueError, match="不存在的证据"):
        grounded_answer.answer_with_evidence("问题", [result])

故障练习

只在提示词写"禁止虚构引用",删除代码中的allowed_ids校验,运行虚构引用测试。观察为什么提示词不能代替程序校验,然后恢复。

今日验收

  • 无证据不调用模型。
  • 模型虚构Chunk ID会被代码拒绝。
  • 能解释检索失败和生成失败的区别。

第13天:建立RAG检索评测

今天必须理解

  1. RAG至少要分开评测检索和生成。
  2. Recall@K回答"正确证据是否进入前K名"。
  3. 测试集需要人工确认预期来源。
  4. AI可以生成候选题,但不能自动把自己生成的答案当黄金标准。

第一步:创建评测集

新建data/retrieval_eval.json

json 复制代码
[
  {
    "question": "AI Agent通常包含哪些部分?",
    "expected_text": "模型、工具、状态和控制流程"
  },
  {
    "question": "高风险写操作应该怎么处理?",
    "expected_text": "人工审批"
  },
  {
    "question": "LangGraph如何组织工作流?",
    "expected_text": "State、Node和Edge"
  }
]

第二步:编写评测脚本

新建evaluate_retrieval.py

python 复制代码
import json
from pathlib import Path

from app.knowledge_base import (
    build_demo_knowledge_base,
)


def evaluate(top_k: int = 2) -> dict:
    cases = json.loads(
        Path(
            "data/retrieval_eval.json"
        ).read_text(encoding="utf-8")
    )
    store = build_demo_knowledge_base()
    hits = 0
    details = []

    for case in cases:
        results = store.search(case["question"], top_k=top_k)
        retrieved_text = "\n".join(
            result.chunk.text for result in results
        )
        hit = case["expected_text"] in retrieved_text
        hits += int(hit)
        details.append(
            {
                "question": case["question"],
                "hit": hit,
                "retrieved": [
                    {
                        "chunk_id": result.chunk.chunk_id,
                        "score": result.score,
                        "text": result.chunk.text,
                    }
                    for result in results
                ],
            }
        )

    return {
        "total": len(cases),
        "hits": hits,
        "recall_at_k": hits / len(cases) if cases else 0,
        "top_k": top_k,
        "details": details,
    }


if __name__ == "__main__":
    report = evaluate(top_k=2)
    output = Path("data/retrieval_report.json")
    output.write_text(
        json.dumps(report, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    print(
        f"Recall@{report['top_k']}: "
        f"{report['recall_at_k']:.2%}"
    )
    print(f"报告:{output}")

执行:

bash 复制代码
python evaluate_retrieval.py

第三步:做参数对比

执行:

bash 复制代码
python - <<'PY'
from evaluate_retrieval import evaluate

for top_k in (1, 2, 3):
    result = evaluate(top_k)
    print(top_k, result["recall_at_k"])
PY

故障练习

修改 Chunk大小后重新评测,不允许只看单个问答效果。记录:

text 复制代码
参数:
Recall@1:
Recall@2:
失败问题:
可能原因:

AI Coding提示词

text 复制代码
根据agent_basics.md生成10个候选检索问题。
每题同时指出依据原文。
我会人工审核后再加入评测集,不要直接写入文件。

今日验收

  • 至少10条人工审核后的评测数据。
  • 修改检索参数后能得到对比报告。
  • 能解释为什么Top-K增大可能提升召回却增加生成噪声。

第14天:把RAG注册成Agent工具

今天必须理解

  1. RAG可以作为工具,由 Agent决定是否使用。
  2. 检索工具应该返回证据,不直接隐藏完整生成流程。
  3. 工具实例的初始化成本不应每次调用都重复发生。
  4. 普通计算问题不应该访问知识库。

第一步:创建知识库服务

新建app/rag_tool.py

python 复制代码
from typing import Any

from pydantic import BaseModel, Field

from app.chunking import split_document
from app.documents import load_text_document
from app.embeddings import TinyHashEmbedding
from app.vector_store import InMemoryVectorStore


class SearchKnowledgeArgs(BaseModel):
    query: str = Field(min_length=1, max_length=500)
    top_k: int = Field(default=3, ge=1, le=8)


def build_store() -> InMemoryVectorStore:
    document = load_text_document(
        "data/agent_basics.md"
    )
    chunks = split_document(document, max_chars=100)
    store = InMemoryVectorStore(TinyHashEmbedding())
    store.add(chunks)
    return store


KNOWLEDGE_STORE = build_store()


def search_knowledge(
    query: str,
    top_k: int = 3,
) -> dict[str, Any]:
    results = KNOWLEDGE_STORE.search(query, top_k=top_k)
    return {
        "ok": bool(results),
        "evidence": [
            {
                "chunk_id": result.chunk.chunk_id,
                "source": result.chunk.source,
                "position": result.chunk.position,
                "text": result.chunk.text,
                "score": result.score,
            }
            for result in results
        ],
    }

第二步:注册工具

app/tools.py中导入:

python 复制代码
from app.rag_tool import (
    SearchKnowledgeArgs,
    search_knowledge,
)

TOOL_REGISTRY中增加:

python 复制代码
"search_knowledge": ToolSpec(
    name="search_knowledge",
    description="检索本地AI Agent学习知识库并返回真实证据",
    args_model=SearchKnowledgeArgs,
    handler=search_knowledge,
),

第三步:增加工具路由测试

tests/test_tools.py中增加:

python 复制代码
def test_search_knowledge_returns_evidence():
    result = execute_tool(
        "search_knowledge",
        {
            "query": "LangGraph如何组织工作流",
            "top_k": 2,
        },
    )

    assert result["ok"] is True
    assert result["evidence"]
    assert "source" in result["evidence"][0]

第四步:真实Agent验证

执行:

bash 复制代码
python - <<'PY'
from pprint import pprint
from app.native_agent import run_native_agent

pprint(run_native_agent("根据知识库说明LangGraph如何组织工作流"))
pprint(run_native_agent("计算12乘以9"))
PY

检查第一题是否调用search_knowledge,第二题是否只调用calculator

故障练习

search_knowledge描述改成模糊的"搜索工具",观察模型工具选择是否变差,然后恢复准确描述。

第二周验收

  • 文档能加载、切分和索引。
  • 检索保留来源和分数。
  • 有Recall@K评测报告。
  • RAG作为工具接入原生循环。
  • 计算问题不会无意义调用知识库。

第三周:LangGraph可靠运行时

第15天:把原生循环迁移为LangGraph

今天必须理解

  1. State保存跨节点数据。
  2. Node执行一个职责并返回状态更新。
  3. Edge表示固定流转。
  4. Conditional Edge根据状态选择下一步。
  5. action="none"和最大轮数都是终止条件。

第一步:创建图状态

新建app/graph_agent.py

python 复制代码
from typing import Any, TypedDict

from langgraph.graph import END, START, StateGraph

from app.config import get_settings
from app.llm_gateway import ask_sync
from app.models import ToolDecision
from app.prompts import build_agent_prompt
from app.tools import execute_tool


class GraphState(TypedDict):
    user_query: str
    observations: list[dict[str, Any]]
    decision: dict[str, Any] | None
    tool_round: int
    final_answer: str
    error: str


def decide_node(state: GraphState) -> dict[str, Any]:
    """调用模型,只负责产生并校验下一步决策。"""
    if state["tool_round"] >= get_settings().max_tool_rounds:
        return {
            "decision": {
                "thought": "达到最大轮数",
                "action": "none",
                "params": {},
                "answer": "任务因达到最大工具轮数而终止。",
            },
            "error": "达到最大工具轮数",
        }

    prompt = build_agent_prompt(
        state["user_query"],
        state["observations"],
    )
    raw = ask_sync([{"role": "user", "content": prompt}])
    try:
        decision = ToolDecision.model_validate_json(raw)
    except Exception as exc:
        return {"error": f"模型输出校验失败:{exc}"}
    return {"decision": decision.model_dump()}


def tool_node(state: GraphState) -> dict[str, Any]:
    """只负责执行已经校验过的工具决策。"""
    decision = state["decision"]
    if not decision:
        return {"error": "缺少工具决策"}

    result = execute_tool(
        decision["action"],
        decision["params"],
    )
    observation = {
        "round": state["tool_round"] + 1,
        "action": decision["action"],
        "params": decision["params"],
        "result": result,
    }
    return {
        "observations": state["observations"] + [observation],
        "tool_round": state["tool_round"] + 1,
    }


def finalize_node(state: GraphState) -> dict[str, str]:
    """统一生成图的最终输出。"""
    if state["error"]:
        return {"final_answer": f"任务失败:{state['error']}"}
    decision = state["decision"] or {}
    return {
        "final_answer": decision.get("answer") or "任务结束但没有答案"
    }


def route_after_decide(state: GraphState) -> str:
    """路由函数只判断,不执行模型、工具或文件写入。"""
    if state["error"]:
        return "finalize"
    decision = state["decision"]
    if not decision or decision["action"] == "none":
        return "finalize"
    return "tool"


def build_graph():
    builder = StateGraph(GraphState)
    builder.add_node("decide", decide_node)
    builder.add_node("tool", tool_node)
    builder.add_node("finalize", finalize_node)

    builder.add_edge(START, "decide")
    builder.add_conditional_edges(
        "decide",
        route_after_decide,
        {
            "tool": "tool",
            "finalize": "finalize",
        },
    )
    builder.add_edge("tool", "decide")
    builder.add_edge("finalize", END)
    return builder.compile()


agent_graph = build_graph()


def initial_state(user_query: str) -> GraphState:
    return {
        "user_query": user_query,
        "observations": [],
        "decision": None,
        "tool_round": 0,
        "final_answer": "",
        "error": "",
    }

第二步:运行图

新建run_graph.py

python 复制代码
from app.graph_agent import (
    agent_graph,
    initial_state,
)


if __name__ == "__main__":
    result = agent_graph.invoke(
        initial_state("计算33乘以6")
    )
    print(result["final_answer"])
    print(result["observations"])

执行:

bash 复制代码
python run_graph.py

第三步:输出图结构

执行:

bash 复制代码
python - <<'PY'
from app.graph_agent import agent_graph
print(agent_graph.get_graph().draw_mermaid())
PY

故障练习

故意让tool_node返回字符串:

python 复制代码
return "done"

运行并观察InvalidUpdateError: Expected dict,理解 LangGraph节点返回值契约后恢复。

AI Coding提示词

text 复制代码
对照native_agent.py和graph_agent.py,
逐项列出while循环中的变量、if和break分别迁移到了哪个State、Node或Edge。
不要修改代码。

今日验收

  • 图能完成一次真实工具循环。
  • 能画出decide→tool→decide→finalize
  • 能解释为什么END通过Edge连接,不能当节点函数注册。
相关推荐
sunly_1 小时前
React useActionState 的用法详解
前端·javascript·react.js
今日无bug1 小时前
RESTful + Interface:从 todos 项目理解后端接口设计
前端·restful
Zane19941 小时前
单例线程安全、生产者消费者、死锁:并发面试三连问串讲
java·后端
用户69371750013841 小时前
#DeepSeek+Pi‑Agent 王炸组合跑赢 Claude‑Code!
前端·人工智能·后端
Zane19941 小时前
类变量与实例变量:一个共享列表引发的线上事故
后端·python
前端一课1 小时前
我还在上班,怎么做一个长期有价值的号
前端
Scene2161 小时前
AgentScope 2.0:2. 快速上手 从零构建生产级智能体
后端
前端一课2 小时前
用 TRAE Work 把项目踩坑经验沉淀成「团队可复用工程规范」,新人再也不重复掉坑
前端·后端
神奇小汤圆2 小时前
一文吃透 Spring 框架:原理、实践与面试全解析
后端