第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项目与配置系统
今天必须理解
- Python包和普通脚本目录的区别。
- 为什么配置不能散落在业务代码中。
.env、系统环境变量和默认值的优先级。- 为什么密钥不能提交到 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数据契约
今天必须理解
- LLM输出合法 JSON不代表业务合法。
TypedDict主要帮助静态检查,Pydantic会执行运行时校验。- 规划结果、工具决策和 API响应不能共用一个模型。
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中找到
required和enum。
第3天:实现统一LLM客户端
今天必须理解
- LiteLLM负责统一不同厂商的调用格式,不负责你的业务兜底。
- 超时、鉴权、限流和空响应必须有不同错误类型。
- 模型错误应该向上抛出领域异常,不要伪装成正常答案。
- 流式响应需要逐块过滤空内容。
第一步:实现同步与异步客户端
新建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与参数校验
今天必须理解
- 工具的代码实现和给模型看的 Schema是两个层面。
- 工具名必须稳定、唯一。
- 参数必须在执行前完成类型和业务校验。
- 工具结果应该是结构化数据,而不只是自然语言。
第一步:创建工具注册表
新建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天:异步并发、超时与单元测试
今天必须理解
async def只表示函数可以被异步调度,不代表内部同步代码自动变快。- 独立的 I/O任务可以并发,有前后依赖的工具不能并发。
- 总任务超时和单次模型超时是两层保护。
- 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服务
今天必须理解
- Web服务启动成功不代表业务接口成功。
- Pydantic请求模型负责入口校验。
HTTPException必须raise。- 同步 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天:文档加载、清洗与元数据
今天必须理解
- RAG首先是数据工程,不是先选向量数据库。
- 每段文本必须保留来源,否则最终无法引用。
- 文档更新需要稳定 ID和内容哈希。
- 空文档、重复文档和编码错误必须显式处理。
第一步:创建文档模型和加载器
新建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接口
今天必须理解
- Chunk太大导致检索不精确,太小会丢失上下文。
- overlap可以缓解边界切断,但会增加索引量和重复结果。
- Embedding模型必须与查询使用同一模型。
- 业务代码不应该绑定某一家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天:实现最小向量索引与检索
今天必须理解
- 索引阶段保存 Chunk向量,查询阶段只计算问题向量。
- 余弦相似度衡量方向相似,不等于事实正确。
- Top-K和阈值控制候选范围。
- 检索层只返回证据,不负责生成最终答案。
第一步:实现内存向量索引
新建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
今天必须理解
- 向量检索擅长语义,关键词检索擅长编号、专有名词和精确文本。
- 混合检索不是把两个分数直接相加,因为分数量纲不同。
- RRF使用排名而不是原始分数融合结果。
- 中文分词质量会直接影响 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天:基于证据生成答案与引用
今天必须理解
- 检索结果是候选证据,不是最终答案。
- 引用必须只能来自实际传入模型的 Chunk。
- "资料不足"应该由证据状态决定,而不是一句提示词碰运气。
- 最终输出也需要独立的数据模型。
第一步:定义引用回答模型
在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检索评测
今天必须理解
- RAG至少要分开评测检索和生成。
- Recall@K回答"正确证据是否进入前K名"。
- 测试集需要人工确认预期来源。
- 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工具
今天必须理解
- RAG可以作为工具,由 Agent决定是否使用。
- 检索工具应该返回证据,不直接隐藏完整生成流程。
- 工具实例的初始化成本不应每次调用都重复发生。
- 普通计算问题不应该访问知识库。
第一步:创建知识库服务
新建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
今天必须理解
- State保存跨节点数据。
- Node执行一个职责并返回状态更新。
- Edge表示固定流转。
- Conditional Edge根据状态选择下一步。
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连接,不能当节点函数注册。