Improve agent documentation for teaching

This commit is contained in:
Shawn Bot
2026-04-02 10:19:32 +00:00
parent 3e979daa61
commit 3fe4d0a060

View File

@@ -1,12 +1,52 @@
"""
教学版 Agent 核心文件。
本文件的目标不是追求最复杂、最完整的工程实现,而是:
1. 用尽量清晰的 Python 代码,演示一个 Agent 的核心结构
2. 保持代码易读、易改、易扩展,方便教学与逐步演进
3. 为后续接入真实 LLM、工具系统、Skills、记忆系统提供稳定骨架
4. 在写法上尽量遵循清晰封装、低耦合、易测试的最佳实践
教学约定:
- 所有文档与注释优先使用中文
- 类、函数、重要属性都尽量提供清晰文档
- 优先追求“容易理解”,再追求“功能丰富”
- 允许用最小可用实现来表达结构,但要保留明确扩展点
"""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable, Iterator, Protocol
@dataclass @dataclass
class Message: class Message:
"""
表示 Agent 运行时中的一条消息。
这是一种教学用的最小消息模型。真实产品中,消息通常会更复杂,
可能还会包含:消息 ID、父子关系、时间戳、工具块、token 使用信息等。
属性:
role:
消息角色。
常见值包括:
- "user":用户消息
- "assistant":模型回复
- "tool_result":工具执行结果
content:
消息正文内容。
这里为了教学简化为纯文本。
meta:
附加元数据。
用于保存额外的结构化信息,例如 turn 编号、chunks、工具名等。
"""
role: str role: str
content: str content: str
meta: dict[str, Any] = field(default_factory=dict) meta: dict[str, Any] = field(default_factory=dict)
@@ -14,41 +54,306 @@ class Message:
@dataclass @dataclass
class ToolResult: class ToolResult:
"""
表示工具执行后的结果。
属性:
ok:
表示工具执行是否成功。
content:
工具返回给 Agent 的文本内容。
在真实系统中,这里也可以扩展为结构化内容。
meta:
工具执行附带的元数据。
例如执行耗时、文件路径、命中条数等。
"""
ok: bool ok: bool
content: str content: str
meta: dict[str, Any] = field(default_factory=dict) meta: dict[str, Any] = field(default_factory=dict)
class Tool: class Tool:
def __init__(self, name: str, description: str, handler: Callable[[dict[str, Any]], ToolResult]): """
表示一个可注册到 Agent 中的工具。
设计思路:
- 工具本身只关心自己的名字、说明和处理函数
- Agent 只依赖统一的 Tool 接口,而不关心具体工具细节
- 这样可以降低 Agent 与具体工具实现之间的耦合
参数:
name:
工具名称,必须唯一。
description:
工具用途说明。
主要用于教学展示、调试和未来做工具提示词时使用。
handler:
工具执行函数。
输入为字典,输出为 ToolResult。
"""
def __init__(
self,
name: str,
description: str,
handler: Callable[[dict[str, Any]], ToolResult],
):
"""
初始化一个工具对象。
参数:
name:
工具名称。
description:
工具用途描述。
handler:
真正执行工具逻辑的函数。
"""
self.name = name self.name = name
self.description = description self.description = description
self.handler = handler self.handler = handler
def call(self, payload: dict[str, Any]) -> ToolResult: def call(self, payload: dict[str, Any]) -> ToolResult:
"""
执行工具。
参数:
payload:
传给工具的输入参数。
返回:
ToolResult工具执行结果。
"""
return self.handler(payload) return self.handler(payload)
class LLMClient(Protocol):
"""
LLM 客户端协议。
这是一个非常重要的教学设计点:
我们不让 Agent 直接依赖某一个具体模型 SDK
而是先定义一个统一接口。这样以后无论接:
- OpenAI
- Anthropic
- LiteLLM
- 本地模型
- 假模型Fake LLM
都可以复用同一个 Agent 主体。
方法:
stream_text:
输入当前消息列表,返回一个文本分块迭代器。
"""
def stream_text(self, messages: list[Message]) -> Iterator[str]:
"""
基于当前消息列表,流式返回文本分块。
参数:
messages:
当前上下文消息列表。
返回:
一个字符串迭代器,每次 yield 一段文本。
"""
...
class FakeLLMClient:
"""
教学用假模型客户端。
这个类的核心价值是:
- 不依赖任何远程 API
- 方便本地测试
- 能模拟“流式输出”的基本行为
- 未来可以被真实 LLM 客户端直接替换
当前行为非常简单:
- 读取最后一条用户消息
- 拼成一段固定风格的回复
- 再把回复切成多个 chunk 流式返回
"""
def stream_text(self, messages: list[Message]) -> Iterator[str]:
"""
根据消息列表流式生成文本。
参数:
messages:
当前消息列表。
返回:
文本块迭代器。
"""
last_user_message = next(
(m.content for m in reversed(messages) if m.role == "user"),
"",
)
response = f"[fake-llm] 你刚才说的是:{last_user_message}"
for chunk in self._chunk_text(response, size=8):
yield chunk
@staticmethod
def _chunk_text(text: str, size: int = 8) -> Iterator[str]:
"""
将一段文本切成多个小块,用于模拟流式返回。
参数:
text:
要切分的完整文本。
size:
每个文本块的最大长度。
返回:
文本块迭代器。
"""
for i in range(0, len(text), size):
yield text[i : i + size]
class Agent: class Agent:
def __init__(self) -> None: """
教学版 Agent 主类。
这是当前项目中最核心的对象。它负责:
- 保存消息
- 管理工具
- 管理简单记忆
- 调用 LLM
- 运行最小主循环
当前版本是教学最小骨架,因此刻意保持简单。
未来可以在这个类上继续演化:
- 权限系统
- system prompt
- tool calling 协议
- verification agent
- transcript 持久化
- context management
参数:
llm:
可注入的 LLM 客户端。
如果不传,则默认使用 FakeLLMClient。
属性:
messages:
当前 Agent 的消息历史。
tools:
已注册工具表key 为工具名value 为 Tool 实例。
memory:
教学版最小记忆列表。
当前只做演示用途。
max_turns:
最大回合数,避免无限循环。
llm:
当前绑定的 LLM 客户端实现。
"""
def __init__(self, llm: LLMClient | None = None) -> None:
"""
初始化 Agent。
参数:
llm:
可选的 LLM 客户端。
不传时使用默认的 FakeLLMClient。
"""
self.messages: list[Message] = [] self.messages: list[Message] = []
self.tools: dict[str, Tool] = {} self.tools: dict[str, Tool] = {}
self.memory: list[str] = [] self.memory: list[str] = []
self.max_turns: int = 20 self.max_turns: int = 20
self.llm: LLMClient = llm or FakeLLMClient()
def register_tool(self, tool: Tool) -> None: def register_tool(self, tool: Tool) -> None:
"""
注册一个工具到 Agent 中。
参数:
tool:
要注册的工具对象。
"""
self.tools[tool.name] = tool self.tools[tool.name] = tool
def add_message(self, role: str, content: str, **meta: Any) -> None: def add_message(self, role: str, content: str, **meta: Any) -> None:
"""
向消息历史中追加一条消息。
参数:
role:
消息角色。
content:
消息文本内容。
**meta:
任意附加元数据。
"""
self.messages.append(Message(role=role, content=content, meta=meta)) self.messages.append(Message(role=role, content=content, meta=meta))
def remember(self, text: str) -> None: def remember(self, text: str) -> None:
"""
向简化记忆列表中加入一条记忆。
参数:
text:
要保存的记忆文本。
"""
self.memory.append(text) self.memory.append(text)
def can_use_tool(self, tool_name: str) -> bool: def can_use_tool(self, tool_name: str) -> bool:
"""
判断某个工具当前是否可用。
当前教学版逻辑非常简单:
- 只判断该工具是否已注册
未来可以扩展为:
- 权限判断
- allow / ask / deny
- agent 角色限制
参数:
tool_name:
工具名称。
返回:
bool是否可用。
"""
return tool_name in self.tools return tool_name in self.tools
def load_skills(self, skills_dir: str | Path) -> list[str]: def load_skills(self, skills_dir: str | Path) -> list[str]:
"""
从目录中发现可用 Skills。
当前教学版规则:
- 递归查找 `SKILL.md`
- skill 名称使用其父目录名
参数:
skills_dir:
Skills 根目录路径。
返回:
已发现的 skill 名称列表。
"""
skills_path = Path(skills_dir) skills_path = Path(skills_dir)
if not skills_path.exists(): if not skills_path.exists():
return [] return []
@@ -58,13 +363,62 @@ class Agent:
loaded.append(path.parent.name) loaded.append(path.parent.name)
return loaded return loaded
def call_llm_stream(self) -> Iterator[str]:
"""
调用当前绑定的 LLM 客户端,并返回流式文本块。
这是一个关键的抽象层。
以后接真实模型时,优先改的是 LLMClient 的实现,
而不是 Agent 主循环本身。
返回:
文本块迭代器。
"""
return self.llm.stream_text(self.messages)
def model_step(self) -> dict[str, Any]: def model_step(self) -> dict[str, Any]:
"""
执行一次模型步骤。
当前教学版逻辑:
- 调用 LLM 流式接口
- 收集所有 chunk
- 组装为一条 message 类型结果
在未来版本中,这里可以继续扩展为:
- message
- tool_call
- subagent_call
- verification_request
返回:
一个描述“本轮模型决定”的字典。
"""
chunks = list(self.call_llm_stream())
return { return {
"type": "message", "type": "message",
"content": "这是一个教学用 Agent 骨架。下一步请在 model_step() 中接入你的模型逻辑。", "content": "".join(chunks),
"chunks": chunks,
} }
def run(self, user_input: str) -> str: def run(self, user_input: str) -> str:
"""
运行 Agent 的最小主循环。
当前版本流程:
1. 记录用户输入
2. 调用 model_step()
3. 如果返回 message则结束
4. 如果未来返回 tool_call则执行工具并继续循环
5. 超过最大轮次则停止
参数:
user_input:
用户输入文本。
返回:
Agent 最终返回给用户的文本。
"""
self.add_message("user", user_input) self.add_message("user", user_input)
for turn in range(self.max_turns): for turn in range(self.max_turns):
@@ -72,7 +426,12 @@ class Agent:
if step["type"] == "message": if step["type"] == "message":
content = step["content"] content = step["content"]
self.add_message("assistant", content, turn=turn) self.add_message(
"assistant",
content,
turn=turn,
chunks=step.get("chunks", []),
)
return content return content
if step["type"] == "tool_call": if step["type"] == "tool_call":
@@ -81,7 +440,12 @@ class Agent:
if not self.can_use_tool(tool_name): if not self.can_use_tool(tool_name):
error = f"Tool not allowed or not found: {tool_name}" error = f"Tool not allowed or not found: {tool_name}"
self.add_message("tool_result", error, ok=False, tool=tool_name) self.add_message(
"tool_result",
error,
ok=False,
tool=tool_name,
)
continue continue
result = self.tools[tool_name].call(tool_input) result = self.tools[tool_name].call(tool_input)
@@ -103,5 +467,19 @@ class Agent:
def echo_tool(payload: dict[str, Any]) -> ToolResult: def echo_tool(payload: dict[str, Any]) -> ToolResult:
"""
一个最简单的教学示例工具。
作用:
把输入文本原样回显回来。
参数:
payload:
工具输入字典。
约定使用 `text` 字段。
返回:
ToolResult工具执行结果。
"""
text = str(payload.get("text", "")) text = str(payload.get("text", ""))
return ToolResult(ok=True, content=f"echo: {text}") return ToolResult(ok=True, content=f"echo: {text}")