大模型应用开发--Agent笔记2(记忆与检索,上下文工程,MCP等通信协议,Skills,Harness,Loop Engineering,含面试常见问题和代码)
写在前面
本文是我的agent开发学习笔记,学习的内容是datawhale的Hello-Agents开源项目(项目链接),以及xhs上搜集到的一些大模型应用开发相关面经的gpt答案。感谢所有内容来源并且尊重datawhale团队的成果。该笔记中的所有Demo都是我自己跑通并略微思考和修改过的。
我的章节和Hello-Agents有些不同,具体来说:
- 第一章:智能体基础概念
- 第二章:各种方式的智能体构建与范式(基础)
- 第三章:性能评估方式
- 第四章:不同的拓展功能(持续更新)
- 第五章:常见面试问题(持续更新)
由于内容过多,因此分成了两篇博客,本篇博客包括我的第四、五章。
Demo没有采用hello-agent打包的框架,而是做成了本地可以运行的最小Demo。后续有时间也会随着不断学习不断更新。
目录
5.1.1.为什么“Prompt 恐吓 + 正则 + 重试”不是好方案?
5.1.2.应用层好的约束:Pydantic + json-repair
Tool Calling / Function Calling 方法
Tool Calling vs Structured Outputs
4.1.记忆与检索
如果智能体无法记住之前的交互内容,也无法从历史经验中学习,那么在连续对话或复杂任务中,其表现将受到极大限制。本节将介绍两个核心能力:记忆系统(Memory System)和检索增强生成(Retrieval-Augmented Generation, RAG)。
4.1.1.为何Agent需要记忆
人类记忆是一个多层级的认知系统,它不仅能存储信息,还能根据重要性、时间和上下文对信息进行分类和整理。

人类记忆可以分为以下几个层次:
- 感觉记忆(Sensory Memory):持续时间极短(0.5-3秒),容量巨大,负责暂时保存感官接收到的所有信息
- 工作记忆(Working Memory):持续时间短(15-30秒),容量有限(7±2个项目),负责当前任务的信息处理
- 长期记忆(Long-term Memory):持续时间长(可达终生),容量几乎无限,进一步分为:
- 程序性记忆:技能和习惯(如骑自行车)
- 陈述性记忆:可以用语言表达的知识,又分为:
- 语义记忆:一般知识和概念(如"巴黎是法国首都")
- 情景记忆:个人经历和事件(如"昨天的会议内容")
一个真正智能的智能体也需要具备记忆能力。对于基于LLM的智能体而言,通常面临两个根本性局限:对话状态的遗忘和内置知识的局限。
(1)局限一:无状态导致的对话遗忘
当前的大语言模型虽然强大,但设计上是无状态的。这意味着,每一次用户请求(或API调用)都是一次独立的、无关联的计算。模型本身不会自动“记住”上一次对话的内容。这带来了几个问题:
- 上下文丢失:在长对话中,早期的重要信息可能会因为上下文窗口限制而丢失
- 个性化缺失:Agent无法记住用户的偏好、习惯或特定需求
- 学习能力受限:无法从过往的成功或失败经验中学习改进
- 一致性问题:在多轮对话中可能出现前后矛盾的回答
要解决这个问题,我们的框架需要引入记忆系统。
(2)局限二:模型内置知识的局限性
除了遗忘对话历史,LLM 的另一个核心局限在于其知识是静态的、有限的。这些知识完全来自于它的训练数据,并因此带来一系列问题:
- 知识时效性:大模型的训练数据有时间截止点,无法获取最新信息
- 专业领域知识:通用模型在特定领域的深度知识可能不足
- 事实准确性:通过检索验证,减少模型的幻觉问题
- 可解释性:提供信息来源,增强回答的可信度
为了克服这一局限,RAG技术应运而生。
在实现上,我们可以将记忆和RAG设计为两个独立的工具:memory_tool负责存储和维护对话过程中的交互信息,rag_tool则负责从用户提供的知识库中检索相关信息作为上下文,并可将重要的检索结果自动存储到记忆系统中。

4.1.2.记忆系统
(这一部分的代码完全来自Hello-Agent教程文档中所展示的,并不是完整的记忆实现,只是核心流程的演示。)
我们需要先定义记忆系统的工作流程。根据认知科学的研究,人类记忆的形成经历以下几个阶段:
- 编码(Encoding):将感知到的信息转换为可存储的形式
- 存储(Storage):将编码后的信息保存在记忆系统中
- 检索(Retrieval):根据需要从记忆中提取相关信息
- 整合(Consolidation):将短期记忆转化为长期记忆
- 遗忘(Forgetting):删除不重要或过时的信息
基于该启发,记忆系统的核心思想是模仿人类大脑处理不同类型信息的方式,将记忆划分为多个专门的模块,并建立一套智能化的管理机制。可以是如下图所示的结构:

该结构由四种不同类型的记忆模块构成,每种模块都针对特定的应用场景和生命周期进行了优化:
- 首先是工作记忆 (Working Memory),它扮演着智能体“短期记忆”的角色,主要用于存储当前对话的上下文信息。为确保高速访问和响应,其容量被有意限制(例如,默认50条),并且生命周期与单个会话绑定,会话结束后便会自动清理。
- 其次是情景记忆 (Episodic Memory),它负责长期存储具体的交互事件和智能体的学习经历。与工作记忆不同,情景记忆包含了丰富的上下文信息,并支持按时间序列或主题进行回顾式检索,是智能体“复盘”和学习过往经验的基础。
- 与具体事件相对应的是语义记忆 (Semantic Memory),它存储的是更为抽象的知识、概念和规则。例如,通过对话了解到的用户偏好、需要长期遵守的指令或领域知识点,都适合存放在这里。这部分记忆具有高度的持久性和重要性,是智能体形成“知识体系”和进行关联推理的核心。
- 最后,为了与日益丰富的多媒体交互,我们引入了感知记忆 (Perceptual Memory)。该模块专门处理图像、音频等多模态信息,并支持跨模态检索。其生命周期会根据信息的重要性和可用存储空间进行动态管理。
MemoryTool
我们采用自顶向下的方式,从MemoryTool支持的具体操作开始,逐步深入到底层实现。MemoryTool(Class)作为记忆系统的统一接口,其设计遵循了"统一入口,分发处理"的架构模式:
def execute(self, action: str, **kwargs) -> str:
"""执行记忆操作
支持的操作:
- add: 添加记忆(支持4种类型: working/episodic/semantic/perceptual)
- search: 搜索记忆
- summary: 获取记忆摘要
- stats: 获取统计信息
- update: 更新记忆
- remove: 删除记忆
- forget: 遗忘记忆(多种策略)
- consolidate: 整合记忆(短期→长期)
- clear_all: 清空所有记忆
"""
if action == "add":
return self._add_memory(**kwargs)
elif action == "search":
return self._search_memory(**kwargs)
elif action == "summary":
return self._get_summary(**kwargs)
# ... 其他操作
add操作是记忆系统的基础,它模拟了人类大脑将感知信息编码为记忆的过程。在实现中,我们不仅要存储记忆内容,还要为每个记忆添加丰富的上下文信息,这些信息将在后续的检索和管理中发挥重要作用。
def _add_memory(
self,
content: str = "",
memory_type: str = "working",
importance: float = 0.5,
file_path: str = None,
modality: str = None,
**metadata
) -> str:
"""添加记忆"""
try:
# 确保会话ID存在
if self.current_session_id is None:
self.current_session_id = f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
# 感知记忆文件支持
if memory_type == "perceptual" and file_path:
inferred = modality or self._infer_modality(file_path)
metadata.setdefault("modality", inferred)
metadata.setdefault("raw_data", file_path)
# 添加会话信息到元数据
metadata.update({
"session_id": self.current_session_id,
"timestamp": datetime.now().isoformat()
})
memory_id = self.memory_manager.add_memory(
content=content,
memory_type=memory_type,
importance=importance,
metadata=metadata,
auto_classify=False
)
return f"✅ 记忆已添加 (ID: {memory_id[:8]}...)"
except Exception as e:
return f"❌ 添加记忆失败: {str(e)}"
这里主要实现了三个关键任务:会话ID的自动管理(确保每个记忆都有明确的会话归属)、多模态数据的智能处理(自动推断文件类型并保存相关元数据)、以及上下文信息的自动补充(为每个记忆添加时间戳和会话信息)。其中,importance参数(默认0.5)用于标记记忆的重要程度,取值范围0.0-1.0,这个机制模拟了人类大脑对不同信息重要性的评估。这种设计让Agent能够自动区分不同时间段的对话,并为后续的检索和管理提供丰富的上下文信息。以下是对不同记忆类型的使用示例:
# 1. 工作记忆 - 临时信息,容量有限
memory_tool.execute("add",
content="用户刚才问了关于Python函数的问题",
memory_type="working",
importance=0.6
)
# 2. 情景记忆 - 具体事件和经历
memory_tool.execute("add",
content="2024年3月15日,用户张三完成了第一个Python项目",
memory_type="episodic",
importance=0.8,
event_type="milestone",
location="在线学习平台"
)
# 3. 语义记忆 - 抽象知识和概念
memory_tool.execute("add",
content="Python是一种解释型、面向对象的编程语言",
memory_type="semantic",
importance=0.9,
knowledge_type="factual"
)
# 4. 感知记忆 - 多模态信息
memory_tool.execute("add",
content="用户上传了一张Python代码截图,包含函数定义",
memory_type="perceptual",
importance=0.7,
modality="image",
file_path="./uploads/code_screenshot.png"
)
search操作是记忆系统的核心功能,它需要在大量记忆中快速找到与查询最相关的内容。它涉及语义理解、相关性计算和结果排序等多个环节。
def _search_memory(
self,
query: str,
limit: int = 5,
memory_types: List[str] = None,
memory_type: str = None,
min_importance: float = 0.1
) -> str:
"""搜索记忆"""
try:
# 参数标准化处理
if memory_type and not memory_types:
memory_types = [memory_type]
results = self.memory_manager.retrieve_memories(
query=query,
limit=limit,
memory_types=memory_types,
min_importance=min_importance
)
if not results:
return f"🔍 未找到与 '{query}' 相关的记忆"
# 格式化结果
formatted_results = []
formatted_results.append(f"🔍 找到 {len(results)} 条相关记忆:")
for i, memory in enumerate(results, 1):
memory_type_label = {
"working": "工作记忆",
"episodic": "情景记忆",
"semantic": "语义记忆",
"perceptual": "感知记忆"
}.get(memory.memory_type, memory.memory_type)
# .get(key, default) 安全取值
# 如果 memory.memory_type 在字典里存在 → 返回对应的中文名;
# 如果 不存在(比如出现了未知类型)→ 返回 memory.memory_type 本身(即原始值)
content_preview = memory.content[:80] + "..." if len(memory.content) > 80 else memory.content
formatted_results.append(
f"{i}. [{memory_type_label}] {content_preview} (重要性: {memory.importance:.2f})"
)
return "\n".join(formatted_results)
except Exception as e:
return f"❌ 搜索记忆失败: {str(e)}"
搜索操作在设计上支持单数和复数两种参数形式(memory_type和memory_types),让用户以最自然的方式表达需求。其中,min_importance参数(默认0.1)用于过滤低质量记忆。对于搜索功能的使用,可以参考以下示例:
# 基础搜索
result = memory_tool.execute("search", query="Python编程", limit=5)
# 指定记忆类型搜索
result = memory_tool.execute("search",
query="学习进度",
memory_type="episodic",
limit=3
)
# 多类型搜索
result = memory_tool.execute("search",
query="函数定义",
memory_types=["semantic", "episodic"],
min_importance=0.5
)
遗忘机制是最具认知科学色彩的功能,它模拟人类大脑的选择性遗忘过程,支持三种策略:基于重要性(删除不重要的记忆)、基于时间(删除过时的记忆)和基于容量(当存储接近上限时删除最不重要的记忆)。
def _forget(self, strategy: str = "importance_based", threshold: float = 0.1, max_age_days: int = 30) -> str:
"""遗忘记忆(支持多种策略)"""
try:
count = self.memory_manager.forget_memories(
strategy=strategy,
threshold=threshold,
max_age_days=max_age_days
)
return f"🧹 已遗忘 {count} 条记忆(策略: {strategy})"
except Exception as e:
return f"❌ 遗忘记忆失败: {str(e)}"
三种遗忘策略的使用:
# 1. 基于重要性的遗忘 - 删除重要性低于阈值的记忆
memory_tool.execute("forget",
strategy="importance_based",
threshold=0.2
)
# 2. 基于时间的遗忘 - 删除超过指定天数的记忆
memory_tool.execute("forget",
strategy="time_based",
max_age_days=30
)
# 3. 基于容量的遗忘 - 当记忆数量超限时删除最不重要的
memory_tool.execute("forget",
strategy="capacity_based",
threshold=0.3
)
consolidate(整合)操作借鉴了神经科学中的记忆固化概念,模拟人类大脑将短期记忆转化为长期记忆的过程。默认设置是将重要性超过0.7的工作记忆转换为情景记忆,这个阈值确保只有真正重要的信息才会被长期保存。整个过程是自动化的,用户无需手动选择具体的记忆,系统会智能地识别符合条件的记忆并执行类型转换。
def _consolidate(self, from_type: str = "working", to_type: str = "episodic", importance_threshold: float = 0.7) -> str:
"""整合记忆(将重要的短期记忆提升为长期记忆)"""
try:
count = self.memory_manager.consolidate_memories(
from_type=from_type,
to_type=to_type,
importance_threshold=importance_threshold,
)
return f"🔄 已整合 {count} 条记忆为长期记忆({from_type} → {to_type},阈值={importance_threshold})"
except Exception as e:
return f"❌ 整合记忆失败: {str(e)}"
记忆整合的使用示例:
# 将重要的工作记忆转为情景记忆
memory_tool.execute("consolidate",
from_type="working",
to_type="episodic",
importance_threshold=0.7
)
# 将重要的情景记忆转为语义记忆
memory_tool.execute("consolidate",
from_type="episodic",
to_type="semantic",
importance_threshold=0.8
)
通过以上几个核心操作协作,MemoryTool构建了一个完整的记忆生命周期管理体系。从记忆的创建、检索、摘要到遗忘、整合和管理,形成了一个闭环的智能记忆管理系统,让Agent真正具备了类人的记忆能力。
MemoryManager
我们深入到底层实现,看看MemoryTool是如何与MemoryManager协作的。这种分层设计体现了软件工程中的关注点分离原则,MemoryTool专注于用户接口和参数处理,而MemoryManager则负责核心的记忆管理逻辑。
MemoryTool在初始化时会创建一个MemoryManager实例,并根据配置启用不同类型的记忆模块。这种设计让用户可以根据具体需求选择启用哪些记忆类型,既保证了功能的完整性,又避免了不必要的资源消耗。
class MemoryTool(Tool):
"""记忆工具 - 为Agent提供记忆功能"""
def __init__(
self,
user_id: str = "default_user",
memory_config: MemoryConfig = None,
memory_types: List[str] = None
):
super().__init__(
name="memory",
description="记忆工具 - 可以存储和检索对话历史、知识和经验"
)
# 初始化记忆管理器
self.memory_config = memory_config or MemoryConfig()
self.memory_types = memory_types or ["working", "episodic", "semantic"]
self.memory_manager = MemoryManager(
config=self.memory_config,
user_id=user_id,
enable_working="working" in self.memory_types,
enable_episodic="episodic" in self.memory_types,
enable_semantic="semantic" in self.memory_types,
enable_perceptual="perceptual" in self.memory_types
)
MemoryManager作为记忆系统的核心协调者,负责管理不同类型的记忆模块,并提供统一的操作接口。
class MemoryManager:
"""记忆管理器 - 统一的记忆操作接口"""
def __init__(
self,
config: Optional[MemoryConfig] = None,
user_id: str = "default_user",
enable_working: bool = True,
enable_episodic: bool = True,
enable_semantic: bool = True,
enable_perceptual: bool = False
):
self.config = config or MemoryConfig()
self.user_id = user_id
# 初始化存储和检索组件
self.store = MemoryStore(self.config)
self.retriever = MemoryRetriever(self.store, self.config)
# 初始化各类型记忆
self.memory_types = {}
if enable_working:
self.memory_types['working'] = WorkingMemory(self.config, self.store)
if enable_episodic:
self.memory_types['episodic'] = EpisodicMemory(self.config, self.store)
if enable_semantic:
self.memory_types['semantic'] = SemanticMemory(self.config, self.store)
if enable_perceptual:
self.memory_types['perceptual'] = PerceptualMemory(self.config, self.store)
四种记忆类型
工作记忆是记忆系统中最活跃的部分,它负责存储当前对话会话中的临时信息。工作记忆的设计重点在于快速访问和自动清理,这种设计确保了系统的响应速度和资源效率。工作记忆采用了纯内存存储方案,配合TTL(Time To Live)机制进行自动清理。这种设计的优势在于访问速度极快,但也意味着工作记忆的内容在系统重启后会丢失。这种特性正好符合工作记忆的定位,存储临时的、易变的信息。
工作记忆的检索采用了混合检索策略,首先尝试使用TF-IDF向量化进行语义检索,如果失败则回退到关键词匹配。这种设计确保了在各种环境下都能提供可靠的检索服务。评分算法结合了语义相似度、时间衰减和重要性权重
class WorkingMemory:
"""工作记忆实现
特点:
- 容量有限(默认50条)+ TTL自动清理
- 纯内存存储,访问速度极快
- 混合检索:TF-IDF向量化 + 关键词匹配
"""
def __init__(self, config: MemoryConfig):
self.max_capacity = config.working_memory_capacity or 50
self.max_age_minutes = config.working_memory_ttl or 60
self.memories = []
def add(self, memory_item: MemoryItem) -> str:
"""添加工作记忆"""
self._expire_old_memories() # 过期清理
if len(self.memories) >= self.max_capacity:
self._remove_lowest_priority_memory() # 容量管理
self.memories.append(memory_item)
return memory_item.id
def retrieve(self, query: str, limit: int = 5, **kwargs) -> List[MemoryItem]:
"""混合检索:TF-IDF向量化 + 关键词匹配"""
self._expire_old_memories()
# 尝试TF-IDF向量检索
vector_scores = self._try_tfidf_search(query)
# 计算综合分数
scored_memories = []
for memory in self.memories:
vector_score = vector_scores.get(memory.id, 0.0)
keyword_score = self._calculate_keyword_score(query, memory.content)
# 混合评分
base_relevance = vector_score * 0.7 + keyword_score * 0.3 if vector_score > 0 else keyword_score
time_decay = self._calculate_time_decay(memory.timestamp)
importance_weight = 0.8 + (memory.importance * 0.4)
final_score = base_relevance * time_decay * importance_weight
if final_score > 0:
scored_memories.append((final_score, memory))
scored_memories.sort(key=lambda x: x[0], reverse=True)
return [memory for _, memory in scored_memories[:limit]]
情景记忆负责存储具体的事件和经历,它的设计重点在于保持事件的完整性和时间序列关系。情景记忆采用了SQLite+Qdrant的混合存储方案,SQLite负责结构化数据的存储和复杂查询,Qdrant负责高效的向量检索。
情景记忆既需要"精确查找"(查询某天发生了什么),也需要"模糊联想"(回忆起和当前问题相似的经历)。单一数据库无法同时满足,混合架构是最优解。情景记忆的检索实现展现了复杂的多因素评分机制。它不仅考虑了语义相似度,还加入了时间近因性的考量,最终通过重要性权重进行调节。
class EpisodicMemory:
"""情景记忆实现
特点:
- SQLite+Qdrant混合存储架构
- 支持时间序列和会话级检索
- 结构化过滤 + 语义向量检索
"""
def __init__(self, config: MemoryConfig):
self.doc_store = SQLiteDocumentStore(config.database_path)
self.vector_store = QdrantVectorStore(config.qdrant_url, config.qdrant_api_key)
self.embedder = create_embedding_model_with_fallback()
self.sessions = {} # 会话索引
def add(self, memory_item: MemoryItem) -> str:
"""添加情景记忆"""
# 创建情景对象
episode = Episode(
episode_id=memory_item.id,
session_id=memory_item.metadata.get("session_id", "default"),
timestamp=memory_item.timestamp,
content=memory_item.content,
context=memory_item.metadata
)
# 更新会话索引
session_id = episode.session_id
if session_id not in self.sessions:
self.sessions[session_id] = []
self.sessions[session_id].append(episode.episode_id)
# 持久化存储(SQLite + Qdrant)
self._persist_episode(episode)
return memory_item.id
def retrieve(self, query: str, limit: int = 5, **kwargs) -> List[MemoryItem]:
"""核心所在:混合检索:结构化过滤 + 语义向量检索"""
# 1. 用SQLite结构化预过滤(时间范围、重要性等)
candidate_ids = self._structured_filter(**kwargs)
# 2. 向量语义检索(超采样为后续重排序留足空间)
hits = self._vector_search(query, limit * 5, kwargs.get("user_id"))
# 3. 综合评分与排序
results = []
for hit in hits:
if self._should_include(hit, candidate_ids, kwargs):
score = self._calculate_episode_score(hit)
memory_item = self._create_memory_item(hit)
results.append((score, memory_item))
results.sort(key=lambda x: x[0], reverse=True)
return [item for _, item in results[:limit]]
def _calculate_episode_score(self, hit) -> float:
"""情景记忆评分算法"""
# 语义相似度(与查询意图最相关的内容优先)
vec_score = float(hit.get("score", 0.0))
# 时间近因性(最近的记忆更有参考价值)
recency_score = self._calculate_recency(hit["metadata"]["timestamp"])
# 重要性
importance = hit["metadata"].get("importance", 0.5)
# 评分公式:(向量相似度 × 0.8 + 时间近因性 × 0.2) × 重要性权重(0.8 ~ 1.2 倍,标记为"重要"的记忆获得额外加分)
base_relevance = vec_score * 0.8 + recency_score * 0.2
importance_weight = 0.8 + (importance * 0.4)
return base_relevance * importance_weight
语义记忆是记忆系统中最复杂的部分,它负责存储抽象的概念、规则和知识。语义记忆的设计重点在于知识的结构化表示和智能推理能力。语义记忆采用了Neo4j图数据库和Qdrant向量数据库的混合架构,这种设计让系统既能进行快速的语义检索,又能利用知识图谱进行复杂的关系推理。
class SemanticMemory(BaseMemory):
"""语义记忆实现
特点:
- 使用HuggingFace中文预训练模型进行文本嵌入
- 向量检索进行快速相似度匹配
- 知识图谱存储实体和关系
- 混合检索策略:向量+图+语义推理
"""
def __init__(self, config: MemoryConfig, storage_backend=None):
super().__init__(config, storage_backend)
# 嵌入模型(统一提供)
self.embedding_model = get_text_embedder()
# 专业数据库存储
self.vector_store = QdrantConnectionManager.get_instance(**qdrant_config)
self.graph_store = Neo4jGraphStore(**neo4j_config)
# 实体和关系缓存
self.entities: Dict[str, Entity] = {}
self.relations: List[Relation] = []
# NLP处理器(支持中英文)
self.nlp = self._init_nlp()
语义记忆的添加过程体现了知识图谱构建的完整流程。系统不仅存储记忆内容,还会自动提取实体和关系,构建结构化的知识表示:
def add(self, memory_item: MemoryItem) -> str:
"""添加语义记忆"""
# 1. 生成文本嵌入
embedding = self.embedding_model.encode(memory_item.content)
# 2. 提取实体和关系
entities = self._extract_entities(memory_item.content)
relations = self._extract_relations(memory_item.content, entities)
# 3. 存储到Neo4j图数据库
for entity in entities:
self._add_entity_to_graph(entity, memory_item)
for relation in relations:
self._add_relation_to_graph(relation, memory_item)
# 4. 存储到Qdrant向量数据库
metadata = {
"memory_id": memory_item.id,
"entities": [e.entity_id for e in entities],
"entity_count": len(entities),
"relation_count": len(relations)
}
self.vector_store.add_vectors(
vectors=[embedding.tolist()],
metadata=[metadata],
ids=[memory_item.id]
)
- Qdrant存储关系数量(relation_count)而非关系本身,这是因为:
- Qdrant是向量检索引擎,不是图数据库。如果把关系结构存入Qdrant也无法做图遍历查询、无法做多跳推理。
- 架构职责分离,Qdrant负责"找到相似的记忆",Neo4j负责"理解记忆之间的关系"。
-
为什么还要存
relation_count:检索时的快速过滤、关系数量越多说明这段记忆包含的信息越复杂、避免每次检索都需要join查询Neo4j获取关系数量。
语义记忆的检索实现了混合搜索策略,结合了向量检索的语义理解能力和图检索的关系推理能力(召回+精排):
def retrieve(self, query: str, limit: int = 5, **kwargs) -> List[MemoryItem]:
"""检索语义记忆"""
# 1. 向量检索
vector_results = self._vector_search(query, limit * 2, user_id)
# 2. 图检索
graph_results = self._graph_search(query, limit * 2, user_id)
# 3. 混合排序
combined_results = self._combine_and_rank_results(
vector_results, graph_results, query, limit
)
return combined_results[:limit]
混合排序算法采用了多因素评分机制,在该例中,语义记忆的评分公式为:(向量相似度 × 0.7 + 图相似度 × 0.3) × (0.8 + 重要性 × 0.4)。这种设计的核心思想是:
- 向量检索权重(0.7):语义相似度是主要因素,确保检索结果与查询语义相关
- 图检索权重(0.3):关系推理作为补充,发现概念间的隐含关联
- 重要性权重范围[0.8, 1.2]:避免重要性过度影响相似度排序,保持检索的准确性
def _combine_and_rank_results(self, vector_results, graph_results, query, limit):
"""混合排序结果"""
combined = {}
# 合并向量和图检索结果
for result in vector_results:
combined[result["memory_id"]] = {
**result,
"vector_score": result.get("score", 0.0),
"graph_score": 0.0
}
for result in graph_results:
memory_id = result["memory_id"]
if memory_id in combined:
combined[memory_id]["graph_score"] = result.get("similarity", 0.0)
else:
combined[memory_id] = {
**result,
"vector_score": 0.0,
"graph_score": result.get("similarity", 0.0)
}
# 计算混合分数
for memory_id, result in combined.items():
vector_score = result["vector_score"]
graph_score = result["graph_score"]
importance = result.get("importance", 0.5)
# 基础相似度得分
base_relevance = vector_score * 0.7 + graph_score * 0.3
# 重要性权重 [0.8, 1.2]
importance_weight = 0.8 + (importance * 0.4)
# 最终得分:相似度 * 重要性权重
combined_score = base_relevance * importance_weight
result["combined_score"] = combined_score
# 排序并返回
sorted_results = sorted(
combined.values(),
key=lambda x: x["combined_score"],
reverse=True
)
return sorted_results[:limit]
感知记忆支持文本、图像、音频等多种模态的数据存储和检索。它采用了模态分离的存储策略,为不同模态的数据创建独立的向量集合,这种设计避免了维度不匹配的问题,同时保证了检索的准确性:
class PerceptualMemory(BaseMemory):
"""感知记忆实现
特点:
- 支持多模态数据(文本、图像、音频等)
- 跨模态相似性搜索
- 感知数据的语义理解
- 支持内容生成和检索
"""
def __init__(self, config: MemoryConfig, storage_backend=None):
super().__init__(config, storage_backend)
# 多模态编码器
self.text_embedder = get_text_embedder()
self._clip_model = self._init_clip_model() # 图像编码
self._clap_model = self._init_clap_model() # 音频编码
# 按模态分离的向量存储
self.vector_stores = {
"text": QdrantConnectionManager.get_instance(
collection_name="perceptual_text",
vector_size=self.vector_dim
),
"image": QdrantConnectionManager.get_instance(
collection_name="perceptual_image",
vector_size=self._image_dim
),
"audio": QdrantConnectionManager.get_instance(
collection_name="perceptual_audio",
vector_size=self._audio_dim
)
}
感知记忆的检索支持同模态和跨模态两种模式。同模态检索利用专业的编码器进行精确匹配,而跨模态检索则需要更复杂的语义对齐机制:
def retrieve(self, query: str, limit: int = 5, **kwargs) -> List[MemoryItem]:
"""检索感知记忆(可筛模态;同模态向量检索+时间/重要性融合)"""
user_id = kwargs.get("user_id")
target_modality = kwargs.get("target_modality")
query_modality = kwargs.get("query_modality", target_modality or "text")
# 同模态向量检索
try:
query_vector = self._encode_data(query, query_modality)
store = self._get_vector_store_for_modality(target_modality or query_modality)
where = {"memory_type": "perceptual"}
if user_id:
where["user_id"] = user_id
if target_modality:
where["modality"] = target_modality
hits = store.search_similar(
query_vector=query_vector,
limit=max(limit * 5, 20),
where=where
)
except Exception:
hits = []
# 融合排序(向量相似度 + 时间近因性 + 重要性权重)
results = []
for hit in hits:
vector_score = float(hit.get("score", 0.0))
recency_score = self._calculate_recency_score(hit["metadata"]["timestamp"])
importance = hit["metadata"].get("importance", 0.5)
# 评分算法
base_relevance = vector_score * 0.8 + recency_score * 0.2
importance_weight = 0.8 + (importance * 0.4)
combined_score = base_relevance * importance_weight
results.append((combined_score, self._create_memory_item(hit)))
results.sort(key=lambda x: x[0], reverse=True)
return [item for _, item in results[:limit]]
def _calculate_recency_score(self, timestamp: str) -> float:
"""计算时间近因性得分"""
try:
memory_time = datetime.fromisoformat(timestamp)
current_time = datetime.now()
age_hours = (current_time - memory_time).total_seconds() / 3600
# 指数衰减:24小时内保持高分,之后逐渐衰减
decay_factor = 0.1 # 衰减系数
recency_score = math.exp(-decay_factor * age_hours / 24)
return max(0.1, recency_score) # 最低保持0.1的基础分数
except Exception:
return 0.5 # 默认中等分数
4.1.3.RAG系统
RAG基础知识
检索增强生成(Retrieval-Augmented Generation,RAG)是一种结合了信息检索和文本生成的技术。它的核心思想是:在生成回答之前,先从外部知识库中检索相关信息,然后将检索到的信息作为上下文提供给大语言模型,从而生成更准确、更可靠的回答。因此,检索增强生成可以拆分为三个词汇。检索是指从知识库中查询相关内容;增强是将检索结果融入提示词,辅助模型生成;生成则输出兼具准确性与透明度的答案。
一个完整的RAG应用流程主要分为两大核心环节。在数据准备阶段,系统通过数据提取、文本分割和向量化,将外部知识构建成一个可检索的数据库。随后在应用阶段,系统会响应用户的提问,从数据库中检索相关信息,将其注入Prompt,并最终驱动大语言模型生成答案。
其发展经历过三个阶段:
- 第一阶段:朴素RAG(Naive RAG, 2020-2021)。这是RAG技术的萌芽阶段,其流程直接而简单,通常被称为“检索-读取”(Retrieve-Read)模式。检索方式:主要依赖传统的关键词匹配算法,如
TF-IDF或BM25。这些方法计算词频和文档频率来评估相关性,对字面匹配效果好,但难以理解语义上的相似性。生成模式:将检索到的文档内容不加处理地直接拼接到提示词的上下文中,然后送给生成模型。 - 第二阶段:高级RAG(Advanced RAG, 2022-2023)。随着向量数据库和文本嵌入技术的成熟,RAG进入了快速发展阶段。研究者和开发者们在“检索”和“生成”的各个环节引入了大量优化技术。检索方式:转向基于稠密嵌入(Dense Embedding)的语义检索。通过将文本转换为高维向量,模型能够理解和匹配语义上的相似性,而不仅仅是关键词。生成模式:引入了很多优化技术,例如查询重写,文档分块,重排序等。
- 第三阶段:模块化RAG(Modular RAG, 2023-至今)。在高级RAG的基础上,现代RAG系统进一步向着模块化、自动化和智能化的方向发展。系统的各个部分被设计成可插拔、可组合的独立模块,以适应更多样化和复杂的应用场景。检索方式:如混合检索,多查询扩展,假设性文档嵌入等。生成模式:思维链推理,自我反思与修正等。
RAG工作原理
其完整工作流程可以如:

RAG系统的两个主要工作模式:
- 数据处理流程:处理和存储知识文档,在这里Hello-Agent教程采取工具
Markitdown,设计思路是将传入的一切外部知识源统一转化为Markdown格式进行处理。 - 查询与生成流程:根据查询检索相关信息并生成回答。
RAG系统架构设计
因为Memory_tool是系统性的实现,而RAG在Hello-Agent的设计中被定义为一种工具,可以梳理为一条pipeline。其的RAG系统的核心架构可以概括为"五层七步"的设计模式:
用户层:RAGTool统一接口
↓
应用层:智能问答、搜索、管理
↓
处理层:文档解析、分块、向量化
↓
存储层:向量数据库、文档存储
↓
基础层:嵌入模型、LLM、数据库
这种分层设计的优势在于每一层都可以独立优化和替换,同时保持整体系统的稳定性。例如,可以轻松地将嵌入模型从sentence-transformers切换到百炼API,而不影响上层的业务逻辑。同样的,这些处理的流程代码是完全可复用的,也可以选取自己需要的部分放进自己的项目中。RAGTool作为RAG系统的统一入口,提供了简洁的API接口。
class RAGTool(Tool):
"""RAG工具
提供完整的 RAG 能力:
- 添加多格式文档(PDF、Office、图片、音频等)
- 智能检索与召回
- LLM 增强问答
- 知识库管理
"""
def __init__(
self,
knowledge_base_path: str = "./knowledge_base",
qdrant_url: str = None,
qdrant_api_key: str = None,
collection_name: str = "rag_knowledge_base",
rag_namespace: str = "default"
):
# 初始化RAG管道
self._pipelines: Dict[str, Dict[str, Any]] = {}
self.llm = HelloAgentsLLM()
# 创建默认管道
default_pipeline = create_rag_pipeline(
qdrant_url=self.qdrant_url,
qdrant_api_key=self.qdrant_api_key,
collection_name=self.collection_name,
rag_namespace=self.rag_namespace
)
self._pipelines[self.rag_namespace] = default_pipeline
整个处理流程如下所示:
任意格式文档 → MarkItDown转换 → Markdown文本 → 智能分块 → 向量化 → 存储检索
RAG系统的核心优势之一是其强大的多模态文档处理能力。系统使用MarkItDown作为统一的文档转换引擎,支持几乎所有常见的文档格式。MarkItDown是微软开源的通用文档转换工具,它是HelloAgents RAG系统的核心组件,负责将任意格式的文档统一转换为结构化的Markdown文本。无论输入是PDF、Word、Excel、图片还是音频,最终都会转换为标准的Markdown格式,然后进入统一的分块、向量化和存储流程。
def _convert_to_markdown(path: str) -> str:
"""
Universal document reader using MarkItDown with enhanced PDF processing.
核心功能:将任意格式文档转换为Markdown文本
支持格式:
- 文档:PDF、Word、Excel、PowerPoint
- 图像:JPG、PNG、GIF(通过OCR)
- 音频:MP3、WAV、M4A(通过转录)
- 文本:TXT、CSV、JSON、XML、HTML
- 代码:Python、JavaScript、Java等
"""
if not os.path.exists(path):
return ""
# 对PDF文件使用增强处理
ext = (os.path.splitext(path)[1] or '').lower()
if ext == '.pdf':
return _enhanced_pdf_processing(path)
# 其他格式使用MarkItDown统一转换
md_instance = _get_markitdown_instance()
if md_instance is None:
return _fallback_text_reader(path)
try:
result = md_instance.convert(path)
markdown_text = getattr(result, "text_content", None)
if isinstance(markdown_text, str) and markdown_text.strip():
print(f"[RAG] MarkItDown转换成功: {path} -> {len(markdown_text)} chars Markdown")
return markdown_text
return ""
except Exception as e:
print(f"[WARNING] MarkItDown转换失败 {path}: {e}")
return _fallback_text_reader(path)
经过MarkItDown转换后,所有文档都统一为标准的Markdown格式。这为后续的智能分块提供了结构化的基础。HelloAgents实现了专门针对Markdown格式的智能分块策略,充分利用Markdown的结构化特性进行精确分割。Markdown结构感知的分块流程:
标准Markdown文本 → 标题层次解析 → 段落语义分割 → Token计算分块 → 重叠策略优化 → 向量化准备
↓ ↓ ↓ ↓ ↓ ↓
统一格式 #/##/### 语义边界 大小控制 信息连续性 嵌入向量
结构清晰 层次识别 完整性保证 检索优化 上下文保持 相似度匹配
由于所有文档都已转换为Markdown格式,系统可以利用Markdown的标题结构(#、##、###等)进行精确的语义分割:
def _split_paragraphs_with_headings(text: str) -> List[Dict]:
"""根据标题层次分割段落,保持语义完整性"""
lines = text.splitlines()
heading_stack: List[str] = []
paragraphs: List[Dict] = []
buf: List[str] = []
char_pos = 0
def flush_buf(end_pos: int):
if not buf:
return
content = "\n".join(buf).strip()
if not content:
return
paragraphs.append({
"content": content,
"heading_path": " > ".join(heading_stack) if heading_stack else None, # 标题层级路径,如 "章节1 > 子章节1.1"
"start": max(0, end_pos - len(content)),
"end": end_pos,
})
for ln in lines:
raw = ln
if raw.strip().startswith("#"):
# 处理标题行
flush_buf(char_pos) # 先保存当前段落
level = len(raw) - len(raw.lstrip('#')) # 计算标题级别
title = raw.lstrip('#').strip()
# 调整标题栈
if level <= 0:
level = 1
if level <= len(heading_stack):
heading_stack = heading_stack[:level-1] # 截断到当前层级
heading_stack.append(title)
char_pos += len(raw) + 1
continue
# 段落内容累积
if raw.strip() == "":
flush_buf(char_pos) # 空行触发段落结束
buf = []
else:
buf.append(raw) # 非空行累积到缓冲区
char_pos += len(raw) + 1
flush_buf(char_pos)
if not paragraphs:
paragraphs = [{"content": text, "heading_path": None, "start": 0, "end": len(text)}]
return paragraphs
在Markdown段落分割的基础上,系统进一步根据Token数量进行智能分块。由于输入已经是结构化的Markdown文本,系统可以更精确地控制分块边界,确保每个分块既适合向量化处理,又保持Markdown结构的完整性:
# 核心策略是以段落为最小单位进行累积,当超出token限制时触发分块并构建重叠部分。
# 重叠机制:通过保留尾部段落实现上下文连贯(保留已累积的段落作为下一个块的起始)
def _chunk_paragraphs(paragraphs: List[Dict], chunk_tokens: int, overlap_tokens: int) -> List[Dict]:
"""基于Token数量的智能分块"""
chunks: List[Dict] = []
cur: List[Dict] = []
cur_tokens = 0
i = 0
while i < len(paragraphs):
p = paragraphs[i]
p_tokens = _approx_token_len(p["content"]) or 1
if cur_tokens + p_tokens <= chunk_tokens or not cur:
cur.append(p)
cur_tokens += p_tokens
i += 1
else:
# 生成当前分块
content = "\n\n".join(x["content"] for x in cur)
start = cur[0]["start"]
end = cur[-1]["end"]
heading_path = next((x["heading_path"] for x in reversed(cur) if x.get("heading_path")), None)
chunks.append({
"content": content,
"start": start,
"end": end,
"heading_path": heading_path,
})
# 构建重叠部分
if overlap_tokens > 0 and cur:
kept: List[Dict] = []
kept_tokens = 0
for x in reversed(cur):
t = _approx_token_len(x["content"]) or 1
if kept_tokens + t > overlap_tokens:
break
kept.append(x)
kept_tokens += t
cur = list(reversed(kept))
cur_tokens = kept_tokens
else:
cur = []
cur_tokens = 0
# 处理最后一个分块
if cur:
content = "\n\n".join(x["content"] for x in cur)
start = cur[0]["start"]
end = cur[-1]["end"]
heading_path = next((x["heading_path"] for x in reversed(cur) if x.get("heading_path")), None)
chunks.append({
"content": content,
"start": start,
"end": end,
"heading_path": heading_path,
})
return chunks
同时为了兼容不同语言,系统实现了针对中英文混合文本的Token估算算法,这对于准确控制分块大小至关重要:
def _approx_token_len(text: str) -> int:
"""近似估计Token长度,支持中英文混合"""
# CJK字符按1 token计算
cjk = sum(1 for ch in text if _is_cjk(ch))
# 其他字符按空白分词计算
non_cjk_tokens = len([t for t in text.split() if t])
return cjk + non_cjk_tokens
def _is_cjk(ch: str) -> bool:
"""判断是否为CJK字符"""
code = ord(ch)
return (
0x4E00 <= code <= 0x9FFF or # CJK统一汉字
0x3400 <= code <= 0x4DBF or # CJK扩展A
0x20000 <= code <= 0x2A6DF or # CJK扩展B
0x2A700 <= code <= 0x2B73F or # CJK扩展C
0x2B740 <= code <= 0x2B81F or # CJK扩展D
0x2B820 <= code <= 0x2CEAF or # CJK扩展E
0xF900 <= code <= 0xFAFF # CJK兼容汉字
)
嵌入模型是RAG系统的核心,它负责将文本转换为高维向量,使得计算机能够理解和比较文本的语义相似性。RAG系统的检索能力很大程度上取决于嵌入模型的质量和向量存储的效率。HelloAgents实现了统一的嵌入接口。在这里为了演示,使用百炼API,如果尚未配置可以切换为本地的all-MiniLM-L6-v2模型,如果两种方案都不支持,也配置了TF-IDF算法来兜底。实际使用可以替换为自己想要的模型或者API:
def index_chunks(
store = None,
chunks: List[Dict] = None,
cache_db: Optional[str] = None,
batch_size: int = 64,
rag_namespace: str = "default"
) -> None:
"""
Index markdown chunks with unified embedding and Qdrant storage.
Uses百炼 API with fallback to sentence-transformers.
"""
if not chunks:
print("[RAG] No chunks to index")
return
# 使用统一嵌入模型
embedder = get_text_embedder()
dimension = get_dimension(384)
# 创建默认Qdrant存储
if store is None:
store = _create_default_vector_store(dimension)
print(f"[RAG] Created default Qdrant store with dimension {dimension}")
# 预处理Markdown文本以获得更好的嵌入质量
processed_texts = []
for c in chunks:
raw_content = c["content"]
processed_content = _preprocess_markdown_for_embedding(raw_content)
processed_texts.append(processed_content)
print(f"[RAG] Embedding start: total_texts={len(processed_texts)} batch_size={batch_size}")
# 批量编码
vecs: List[List[float]] = []
for i in range(0, len(processed_texts), batch_size):
part = processed_texts[i:i+batch_size]
try:
# 使用统一嵌入器(内部处理缓存)
part_vecs = embedder.encode(part)
# 标准化为List[List[float]]格式
if not isinstance(part_vecs, list):
if hasattr(part_vecs, "tolist"):
part_vecs = [part_vecs.tolist()]
else:
part_vecs = [list(part_vecs)]
# 处理向量格式和维度
for v in part_vecs:
try:
if hasattr(v, "tolist"):
v = v.tolist()
v_norm = [float(x) for x in v]
# 维度检查和调整
if len(v_norm) != dimension:
print(f"[WARNING] 向量维度异常: 期望{dimension}, 实际{len(v_norm)}")
if len(v_norm) < dimension:
v_norm.extend([0.0] * (dimension - len(v_norm)))
else:
v_norm = v_norm[:dimension]
vecs.append(v_norm)
except Exception as e:
print(f"[WARNING] 向量转换失败: {e}, 使用零向量")
vecs.append([0.0] * dimension)
except Exception as e:
print(f"[WARNING] Batch {i} encoding failed: {e}")
# 实现重试机制
# ... 重试逻辑 ...
print(f"[RAG] Embedding progress: {min(i+batch_size, len(processed_texts))}/{len(processed_texts)}")
高级检索策略
在实际应用中,用户的查询表述与文档中的实际内容可能存在用词差异,导致相关文档无法被检索到。为了解决这个问题,HelloAgents实现了三种互补的高级检索策略:多查询扩展(MQE)、假设文档嵌入(HyDE)和统一的扩展检索框架。
多查询扩展(Multi-Query Expansion)是一种通过生成语义等价的多样化查询来提高检索召回率的技术。这种方法的核心洞察是:同一个问题可以有多种不同的表述方式,而不同的表述可能匹配到不同的相关文档。例如,"如何学习Python"可以扩展为"Python入门教程"、"Python学习方法"、"Python编程指南"等多个查询。通过并行执行这些扩展查询并合并结果,系统能够覆盖更广泛的相关文档,避免因用词差异而遗漏重要信息。MQE的优势在于它能够自动理解用户查询的多种可能含义,特别是对于模糊查询或专业术语查询效果显著。系统使用LLM生成扩展查询,确保扩展的多样性和语义相关性:
prompt = [
{"role": "system", "content": "你是检索查询扩展助手。生成语义等价或互补的多样化查询。使用中文,简短,避免标点。"},
{"role": "user", "content": f"原始查询:{query}\n请给出{n}个不同表述的查询,每行一个。"}
]
假设文档嵌入(Hypothetical Document Embeddings)是一种创新的检索技术,它的核心思想是"用答案找答案"。传统的检索方法是用问题去匹配文档,但问题和答案在语义空间中的分布往往存在差异——问题通常是疑问句,而文档内容是陈述句。HyDE通过让LLM先生成一个假设性的答案段落,然后用这个答案段落去检索真实文档,从而缩小了查询和文档之间的语义鸿沟。这种方法的优势在于,假设答案与真实答案在语义空间中更加接近,因此能够更准确地匹配到相关文档。即使假设答案的内容不完全正确,它所包含的关键术语、概念和表述风格也能有效引导检索系统找到正确的文档。特别是对于专业领域的查询,HyDE能够生成包含领域术语的假设文档,显著提升检索精度:(只用生成的答案去检索,如果用问题+答案,会引入噪音,偏离真实文档分布,embedding既不像Query也不像Document,并且Query信息已经包含在Answer中,会造成信息冗余)
prompt = [
{"role": "system", "content": "根据用户问题,先写一段可能的答案性段落,用于向量检索的查询文档(不要分析过程)。"},
{"role": "user", "content": f"问题:{query}\n请直接写一段中等长度、客观、包含关键术语的段落。"}
]
HelloAgents将MQE和HyDE两种策略整合到统一的扩展检索框架中。系统通过enable_mqe和enable_hyde参数让用户可以根据具体场景选择启用哪些策略:对于需要高召回率的场景可以同时启用两种策略,对于性能敏感的场景可以只使用基础检索。扩展检索的核心机制是"扩展-检索-合并"三步流程。首先,系统根据原始查询生成多个扩展查询(包括MQE生成的多样化查询和HyDE生成的假设文档);然后,对每个扩展查询并行执行向量检索,获取候选文档池;最后,通过去重和分数排序合并所有结果,返回最相关的top-k文档。这种设计的巧妙之处在于,它通过candidate_pool_multiplier参数(默认为4)扩大候选池,确保有足够的候选文档进行筛选,同时通过智能去重避免返回重复内容。
MQE擅长处理用词多样性问题,HyDE擅长处理语义鸿沟问题,而统一框架则确保了结果的质量和多样性。对于一般查询,建议启用MQE;对于专业领域查询,建议同时启用MQE和HyDE;对于性能敏感场景,可以只使用基础检索或仅启用MQE。
4.2.上下文工程
要让智能体在真实复杂场景中稳定地“思考”与“行动”,仅有记忆与检索还不够——我们需要一套工程化方法,持续、系统地为模型构造恰当的“上下文”。这就是本章的主题:上下文工程(Context Engineering)。它关注的是“在每一次模型调用前,如何以可复用、可度量、可演进的方式,拼装并优化输入上下文”,从而提升正确性、鲁棒性与效率。
本章主要介绍上下文工程的核心概念与实践,并在HelloAgents框架中新增了上下文构建器和两个配套工具:
- ContextBuilder:上下文构建器,实现 GSSC (Gather-Select-Structure-Compress) 流水线,提供统一的上下文管理接口
- NoteTool:结构化笔记工具,支持智能体进行持久化记忆管理
- TerminalTool:终端工具,支持智能体进行文件系统操作和即时上下文检索
这些组件共同构成了完整的上下文工程解决方案,是实现长时程任务管理和智能体式搜索的关键。
(个人感觉,可以说记忆系统是上下文工程的一个子集)
4.2.1.什么是上下文工程

| 概念 | 核心思想 | 工作方式 | 局限性 |
|---|---|---|---|
| 提示词工程 | 问对问题 | 精心设计一个完美的 Prompt | 知识过时,无法与外部世界交互 |
| RAG | 给予参考资料 | 提问前先从知识库检索相关信息 | 被动响应,无法执行任务,依赖知识库 |
| Agent | 赋予行动能力 | 通过“思考-行动”循环来使用工具、完成任务 | 复杂,不稳定,成本高 |
| 上下文工程 | 打造完美输入 | 系统性地收集、筛选、压缩、格式化所有信息,为模型提供最优上下文 | 是一个方法论/学科,而非具体系统,实现复杂 |
如今,用语言模型构建系统不再只是找对提示词里的句式和措辞,而是要回答一个更宏观的问题:什么样的上下文配置,最有可能让模型产出我们期望的行为?所谓“上下文”,是指在对大语言模型(LLM)进行采样时所包含的那组 tokens。手头的工程问题,是在 LLM 的固有约束之下,优化这些 tokens 的效用,以便稳定地得到预期结果。想要有效驾驭 LLM,往往需要“在上下文中思考”——也就是说:在任何一次调用时,都要审视 LLM 可见的整体状态,并预判这种状态可能诱发的行为。

上下文工程 vs. 提示工程
如上图所示,在现在前沿模型厂商的视角中,上下文工程是提示工程的自然演进。提示工程关注如何编写与组织 LLM 的指令以获得更优结果(例如系统提示的写法与结构化策略);而上下文工程则是在推理阶段,如何策划与维护“最优的信息集合(tokens)”,其中不仅包含提示本身,还包含其他会进入上下文窗口的一切信息。
在 LLM 工程的早期阶段,提示往往是主要工作,因为大多数用例(除日常聊天外)都需要针对单轮分类或文本生成做精调式的提示优化。顾名思义,提示工程的核心是“如何写出有效提示”,尤其是系统提示。然而,随着我们开始工程化地构建更强的智能体,它们在更长的时间范围内、跨多次推理轮次地工作,我们就需要能管理整个上下文状态的策略——其中包括系统指令、工具、MCP(Model Context Protocol)、外部数据、消息历史等。
一个循环运行的智能体,会不断产生下一轮推理可能相关的数据,这些信息必须被周期性地提炼。因此,上下文工程的“艺与术”,在于从持续扩张的“候选信息宇宙”中,甄别哪些内容应当进入有限的上下文窗口。
上下文工程为什么重要
目前的大模型会有这样的一个问题:上下文腐蚀(context rot)——随着上下文窗口中的 tokens 增加,模型从上下文中准确回忆信息的能力反而下降。不同模型的退化曲线或许更平滑,但这一特征几乎在所有模型上都会出现。因此,上下文必须被视作一种有限资源,且具有边际收益递减。就像人类有有限的工作记忆容量一样,LLM 也有一笔“注意力预算”。每新增一个 token,都会消耗这笔预算的一部分。
这种稀缺并非偶然,而是源自 LLM 的架构约束。Transformer 让每个 token 能够与上下文中的所有 token 建立关联,理论上形成 (n^2) 级别的两两注意力关系。随着上下文长度增长,模型对这些两两关系的建模能力会被“拉薄”,从而自然地产生“上下文规模”与“注意力集中度”的张力。此外,模型的注意力模式来源于训练数据分布——短序列通常比长序列更常见,因此模型对“全上下文依赖”的经验更少、专门参数也更少。
优秀的上下文工程目标是:用尽可能少、但高信号密度的 tokens,最大化获得期望结果的概率。
工程实践正在从“推理前一次性检索(embedding 检索)”逐步过渡到“及时(Just-in-time, JIT)上下文”。后者不再预先加载所有相关数据,而是维护轻量化引用(文件路径、存储查询、URL 等),在运行时通过工具动态加载所需数据。除了存储效率,引用的元数据本身也能帮助精化行为:目录层级、命名约定、时间戳等都在隐含地传达“目的与时效”。
需要权衡的是:运行时探索往往比预计算检索更慢,并且需要有“主见”的工程设计来确保模型拥有正确的工具与启发式。如果缺少引导,智能体可能会误用工具、追逐死胡同或错过关键信息,造成上下文浪费。在不少场景中,混合策略更有效:前置加载少量“高价值”上下文以保证速度,然后允许智能体按需继续自主探索。边界的选择取决于任务动态性与时效要求。在工程上,可以预先放入类似“项目约定说明(如 README/指南)”的文件,同时提供 glob、grep 等原语,让智能体即时检索具体文件,从而绕开过时索引与复杂语法树的沉没成本。
长时程任务要求智能体在超出上下文窗口的长序列行动中,仍能保持连贯性、上下文一致与目标导向。指望无限增大上下文窗口并不能根治“上下文污染”与相关性退化的问题,因此需要直接面向这些约束的工程手段:压缩整合(Compaction)、结构化笔记(Structured note-taking)与子代理架构(Sub-agent architectures)。
- 压缩整合(Compaction)
- 定义:当对话接近上下文上限时,对其进行高保真总结,并用该摘要重启一个新的上下文窗口,以维持长程连贯性。
- 实践:让模型压缩并保留架构性决策、未解决缺陷、实现细节,丢弃重复的工具输出与噪声;新窗口携带压缩摘要 + 最近少量高相关工件(如“最近访问的若干文件”)。
- 调参建议:先优化召回(确保不遗漏关键信息),再优化精确度(剔除冗余内容);一种安全的“轻触式”压缩是对“深历史中的工具调用与结果”进行清理。
- 结构化笔记(Structured note-taking)
- 定义:也称“智能体记忆”。智能体以固定频率将关键信息写入上下文外的持久化存储,在后续阶段按需拉回。
- 价值:以极低的上下文开销维持持久状态与依赖关系。例如维护 TODO 列表、项目 NOTES.md、关键结论/依赖/阻塞项的索引,跨数十次工具调用与多轮上下文重置仍能保持进度与一致性。
- 说明:在非编码场景中同样有效(如长期策略性任务、游戏/仿真中的目标管理与统计计数)。
- 子代理架构(Sub-agent architectures)
- 思想:由主代理负责高层规划与综合,多个专长子代理在“干净的上下文窗口”中各自深挖、调用工具并探索,最后仅回传凝练摘要(常见 1,000–2,000 tokens)。
- 好处:实现关注点分离。庞杂的搜索上下文留在子代理内部,主代理专注于整合与推理;适合需要并行探索的复杂研究/分析任务。
- 经验:公开的多智能体研究系统显示,该模式在复杂研究任务上相较单代理基线具有显著优势。
方法取舍可以遵循以下经验法则:
- 压缩整合:适合需要长对话连续性的任务,强调上下文的“接力”。
- 结构化笔记:适合有里程碑/阶段性成果的迭代式开发与研究。
- 子代理架构:适合复杂研究与分析,能从并行探索中获益。
4.2.2.上下文构建器
ContextBuilder 的设计理念是"简单高效",去除不必要的复杂性,统一以"相关性+新近性"的分数进行选择,符合 Agent 模块化与可维护性的工程取向。
一个优秀的上下文管理系统应该解决以下几个关键问题:
-
统一入口:将"获取(Gather)- 选择(Select)- 结构化(Structure)- 压缩(Compress)"抽象为可复用流水线,减少在 Agent 实现中的重复模板代码。这种统一的接口设计让开发者无需在每个 Agent 中重复编写上下文管理逻辑。
-
稳定形态:输出固定骨架的上下文模板,便于调试、A/B 测试与评估。我们采用了分区组织的模板结构:
[Role & Policies]:明确 Agent 的角色定位和行为准则[Task]:当前需要完成的具体任务[State]:Agent 的当前状态和上下文信息[Evidence]:从外部知识库检索的证据信息[Context]:历史对话和相关记忆[Output]:期望的输出格式和要求
-
预算守护:在 token 预算内尽量保留高价值信息,对超限上下文提供兜底压缩策略。这确保了即使在信息量巨大的场景下,系统也能稳定运行。
-
最小规则:不引入来源/优先级等分类维度,避免复杂度增长。实践表明,基于相关性和新近性的简单评分机制,在大多数场景下已经足够有效。
在实际应用 ContextBuilder 时,以下几点最佳实践值得注意:
- 动态调整 token 预算:根据任务复杂度动态调整
max_tokens,简单任务使用较小预算,复杂任务增加预算。 - 相关性计算优化:在生产环境中,将简单的关键词重叠替换为向量相似度计算,提升检索质量。
- 缓存机制:对于不变的系统指令和知识库内容,可以实现缓存机制,避免重复计算。
- 监控与日志:记录每次上下文构建的统计信息(选中信息数量、token 使用率等),便于后续优化。
- A/B 测试:对于关键参数(如相关性权重、新近性权重),通过 A/B 测试找到最优配置。
4.2.3.结构化笔记工具
顾名思义,NoteTool就是记笔记用的,可以用于长期项目追踪--记录当前的任务状态、关键结论、阻塞点、下一步计划;研究任务管理--记录每篇论文的核心观点、待深入调研的主题、重要参考文献。
每个笔记都是一个独立的 .md 文件,格式可以如下:
---
id: note_20250119_153000_0
title: 项目进展 - 第一阶段
type: task_state
tags: [refactoring, phase1, backend]
created_at: 2025-01-19T15:30:00
updated_at: 2025-01-19T15:30:00
---
# 项目进展 - 第一阶段
## 完成情况
已完成数据模型层的重构,主要改动包括:
1. 统一了实体类的命名规范
2. 引入了类型提示,提升代码可维护性
3. 优化了数据库查询性能
## 测试覆盖
- 单元测试覆盖率: 85%
- 集成测试覆盖率: 70%
## 下一步计划
1. 重构业务逻辑层
2. 解决依赖冲突问题
3. 提升集成测试覆盖率至85%
采用 Markdown (人类可读且格式丰富)+ YAML(机器可解析) 的混合格式,这种设计兼顾了结构化和可读性。
NoteTool 还维护一个 notes_index.json 文件,用于快速检索和管理笔记,用于:
- 快速检索:无需打开每个文件,直接从索引中查找
- 元数据管理:集中管理所有笔记的元数据
- 完整性校验:可以检测文件缺失或损坏
{
"note_20250119_153000_0": {
"id": "note_20250119_153000_0",
"title": "项目进展 - 第一阶段",
"type": "task_state",
"tags": ["refactoring", "phase1", "backend"],
"created_at": "2025-01-19T15:30:00",
"updated_at": "2025-01-19T15:30:00",
"file_path": "./notes/note_20250119_153000_0.md"
}
}
一个note的生命周期可以是:
- create(标题、内容、类型、标签列表、唯一ID、创建时间、更新时间)
- 构建id
- 构建元数据
- 构建markdown内容
- 保存
- 更新索引
- search
- read
- update
- list(列出所有笔记,按指定的tag或排序方式)
- count(笔记数量统计)/summary(笔记摘要)
- delete
在实际使用 NoteTool 时,以下最佳实践能帮助构建更强大的长时程智能体:
-
合理的笔记分类:
task_state:记录阶段性进展和状态conclusion:记录重要的结论和发现blocker:记录阻塞问题,优先级最高action:记录下一步行动计划reference:记录重要的参考资料
-
定期清理和归档:
- 对于已解决的 blocker,更新为 conclusion
- 对于过时的 action,及时删除或更新
- 使用 tags 进行版本管理,如
["v1.0", "completed"]
-
与 ContextBuilder 的配合:
- 在每轮对话前检索相关笔记
- 根据笔记类型设置不同的相关性分数(blocker > action > conclusion)
- 限制笔记数量,避免上下文过载
-
人机协作:
- 笔记是人类可读的 Markdown 格式,支持手动编辑
- 使用 Git 进行版本控制,追踪笔记的演化
- 在关键阶段,人工审核 Agent 生成的笔记
-
自动化工作流:
- 定期生成笔记摘要报告
- 基于笔记自动生成项目进度文档
- 将笔记内容同步到其他系统(如 Notion、Confluence)
4.2.4.终端文件访问工具
在许多实际场景中,智能体需要即时访问和探索文件系统——查看日志文件、分析代码库结构、检索配置文件等。这就是 TerminalTool 的用武之地。TerminalTool 为智能体提供了安全的命令行执行能力,支持常用的文件系统和文本处理命令,同时通过多层安全机制确保系统安全。这种设计实现了之前3.2.1中提到的"即时(Just-in-time, JIT)上下文"理念——智能体不需要预先加载所有文件,而是按需探索和检索。
其使用场景的特点是需要实时、轻量级的文件系统访问,而不是预先索引和向量化。
允许智能体执行命令是一个强大但危险的能力。TerminalTool 需要通过多层安全机制确保系统安全:
- 第一层:命令白名单,完全禁止任何可能修改系统的操作
- 第二层:工作目录限制(沙箱),只能访问指定的工作目录及其子目录,无法访问系统其他部分
- 第三层:超时控制,每个命令都有执行时间限制,防止无限循环或资源耗尽
- 第四层:输出大小限制,防止内存溢出
4.2.5.上下文挑战
上下文挑战主要存在四个方面,分别描述为:
- 上下文污染/毒化 - 当幻觉进入上下文时
- 嵌入错误信息,导致代理(agent)性能脱轨。这种情况会“毒化”关键部分,如目标或摘要,使得模型固执于不可能或无关的目标,导致重复的、无意义的的行为。
- 上下文干扰/分散 - 当上下文压倒了训练数据时
- 模型过度依赖历史细节,而忽略其预训练知识或生成新颖解决方案的能力。这会引发重复动作而非创造性问题解决,且性能在上下文窗口满载前就已下降。
- 上下文混淆 - 当多余的上下文影响响应时
- 无关或多余的信息(如冗余工具定义)被纳入上下文,迫使模型考虑它,从而产生次优响应。即使额外内容无害,也会稀释焦点并降低质量。在长上下文里,模型不光要找到相关信息,还要能分辨“哪个才是正确的 needle,哪个只是干扰项”。
- 上下文冲突 - 当上下文各部分不一致时
- 是混淆的更严重形式,指上下文中的信息相互冲突(如新工具或事实与现有内容矛盾),从而破坏推理,通常因为模型锁定在早期假设中。这比单纯无关更具破坏性,在多步交互中,早期的错误会传播,模型依赖于有缺陷的前提。
为解决上述挑战,上下文工程的策略主要分为四种:写入(存储)、选择、压缩和隔离。
4.3.智能体通信协议
在前面的章节中,我们构建了功能完备的单体智能体,它们具备推理、工具调用和记忆能力。然而,当我们尝试构建更复杂的 AI 系统时,自然会有疑问:如何让智能体与外部世界高效交互?如何让多个智能体相互协作?
这正是智能体通信协议要解决的核心问题。本章将为 HelloAgents 框架引入三种通信协议:MCP(Model Context Protocol)用于智能体与工具的标准化通信,A2A(Agent-to-Agent Protocol)用于智能体间的点对点协作,ANP(Agent Network Protocol)用于构建大规模智能体网络。这三种协议共同构成了智能体通信的基础设施层。
4.3.1.通信协议引言
对于一个简单的单智能体,如2.1.1节中的demo,这个智能体工作得很好,但它面临着三个根本性的限制。首先是工具集成的困境:每当需要访问新的外部服务(如 GitHub API、数据库、文件系统),我们都必须编写专门的 Tool 类。这不仅工作量大,而且不同开发者编写的工具无法互相兼容。其次是能力扩展的瓶颈:智能体的能力被限制在预先定义的工具集内,无法动态发现和使用新的服务。最后是协作的缺失:当任务复杂到需要多个专业智能体协作时(如研究员+撰写员+编辑),我们只能通过手动编排来协调它们的工作。这种方式存在明显的问题:代码重复(每个工具都要处理 HTTP 请求、错误处理、认证等),难以维护(API 变更需要修改所有相关工具),无法复用(其他开发者的工具无法直接使用),扩展性差(添加新服务需要大量编码工作)。
通信协议的核心价值正是解决这些问题。它提供了一套标准化的接口规范,让智能体能够以统一的方式访问各种外部服务,而无需为每个服务编写专门的适配器。这就像互联网的 TCP/IP 协议,它让不同的设备能够相互通信,而不需要为每种设备编写专门的通信代码。
有了通信协议,就可以把这样的繁杂代码
# 传统方式:手动集成每个服务
class GitHubTool(BaseTool):
"""需要手写GitHub API适配器"""
def run(self, repo_url):
# 大量的API调用代码...
pass
class DatabaseTool(BaseTool):
"""需要手写数据库适配器"""
def run(self, query):
# 数据库连接和查询代码...
pass
class WeatherTool(BaseTool):
"""需要手写天气API适配器"""
def run(self, location):
# 天气API调用代码...
pass
# 每个新服务都需要重复这个过程
agent.add_tool(GitHubTool())
agent.add_tool(DatabaseTool())
agent.add_tool(WeatherTool())
简化成:
from hello_agents.tools import MCPTool
# 连接到MCP服务器,自动获得所有工具
mcp_tool = MCPTool() # 内置服务器提供基础工具
# 或者连接到专业的MCP服务器
github_mcp = MCPTool(server_command=["npx", "-y", "@modelcontextprotocol/server-github"])
database_mcp = MCPTool(server_command=["python", "database_mcp_server.py"])
# 智能体自动获得所有能力,无需手写适配器
agent.add_tool(mcp_tool)
agent.add_tool(github_mcp)
agent.add_tool(database_mcp)
标准化接口让不同服务提供统一的访问方式,互操作性使得不同开发者的工具可以无缝集成,动态发现允许智能体在运行时发现新的服务和能力,可扩展性让系统能够轻松添加新的功能模块。
4.3.2.MCP协议
MCP(Model Context Protocol)由 Anthropic 团队提出,其核心设计理念是标准化智能体与外部工具/资源的通信方式。MCP 的设计哲学是"上下文共享"。它不仅仅是一个 RPC(远程过程调用)协议,更重要的是它允许智能体和工具之间共享丰富的上下文信息。例如当智能体访问一个代码仓库时,MCP 服务器不仅能提供文件内容,还能提供代码结构、依赖关系、提交历史等上下文信息,让智能体能够做出更智能的决策。
在 Agent/MCP 工具函数里最好写类型注释,因为它能帮助框架生成工具说明,也能帮助模型更准确地调用工具。不是“MCP 协议本身”看你的 Python 类型注释,而是 MCP 的 Python server 框架,比如 FastMCP,会读取你写的类型注解,然后自动生成工具的 schema。
MCP 概念介绍
MCP 就像 USB-C 统一了各种设备的连接方式一样,MCP 统一了智能体与外部工具的交互方式。无论你使用 Claude、GPT 还是其他模型,只要它们支持 MCP 协议,就能无缝访问相同的工具和资源。
MCP 协议采用 Host、Client、Servers 三层架构设计,三层架构的职责:
- Host(宿主层):Claude Desktop 作为 Host,负责接收用户提问并与 Claude 模型交互。Host 是用户直接交互的界面,它管理整个对话流程。
- Client(客户端层):当 Claude 模型决定需要访问文件系统时,Host 中内置的 MCP Client 被激活。Client 负责与适当的 MCP Server 建立连接,发送请求并接收响应。
- Server(服务器层):文件系统 MCP Server 被调用,执行实际的文件扫描操作,访问桌面目录,并返回找到的文档列表。
完整的交互流程:用户问题 → Claude Desktop(Host) → Claude 模型分析 → 需要文件信息 → MCP Client 连接 → 文件系统 MCP Server → 执行操作 → 返回结果 → Claude 生成回答 → 显示在 Claude Desktop 上
这种架构设计的优势在于关注点分离:Host 专注于用户体验,Client 专注于协议通信,Server 专注于具体功能实现。开发者只需专注于开发对应的 MCP Server,无需关心 Host 和 Client 的实现细节。

MCP 工作流程

当用户提出问题时,完整的工具选择流程如下:
- 工具发现阶段:MCP Client 连接到 Server 后,首先调用
list_tools()获取所有可用工具的描述信息(包括工具名称、功能说明、参数定义) - 上下文构建:Client 将工具列表转换为 LLM 能理解的格式,添加到系统提示词中。例如:
你可以使用以下工具: - read_file(path: str): 读取指定路径的文件内容 - search_code(query: str, language: str): 在代码库中搜索 - 模型推理:LLM 分析用户问题和可用工具,决定是否需要调用工具以及调用哪个工具。这个决策基于工具的描述和当前对话上下文
- 工具执行:如果 LLM 决定使用工具,Client 通过 MCP Server 执行所选工具,获取结果
- 结果整合:工具执行结果被送回给 LLM,LLM 结合结果生成最终回答
这个过程是完全自动化的,LLM 会根据工具描述的质量来决定是否使用以及如何使用工具。因此,编写清晰、准确的工具描述至关重要。
理解FunctionCall和一些其他问题
工具调用本质上模型不会真正调用函数,它只是在(结构化输出并)预测:该不该调用、调用哪个工具、生成什么参数。真正的参数校验、函数执行、异常处理和结果回传,都由你的程序负责。
- 工具描述怎么写,模型才不容易乱编参数:说明“工具能做什么”和“使用边界”(什么时候用,缺少信息时怎么办);参数描述要写“数据语义”,不能只写数据类型;明确 required,不要让模型判断哪些字段必填;模型只生成表达用户意图所必需的参数,系统状态和安全参数由程序注入;一个工具不要承担太多完全不同的功能;名称要体现动作和对象。
- 解析失败怎么兜底?首先用 Pydantic 做二次校验并区分不同类型的失败,json解析失败让模型进行一次定向修复;明确信息缺失必须向用户询问;工具执行失败执行器根据错误类型决定;限制自动修复次数。
- 上下文多了之后模型容易乱选function该怎么办?每一轮只向模型提供当前可能使用的少量工具,可以做意图判断、工具索引等。也可以设计总调度器只负责选择子 Agent,不直接看到底层所有工具,子 Agent 只看到自己领域的工具。在工具描述中写清楚工具之间的差别,尤其是读/写操作要分明;同时应该维护一个结构化状态,而不是要求模型每次从几十轮聊天里重新抽取。
MCP 与 Function Calling 的差异
首先需要明确的是,Function Calling 与 MCP 并非竞争关系,而是相辅相成的。Function Calling 是大语言模型的一项核心能力,它体现了模型内在的智能,使模型能够理解何时需要调用函数,并精准生成相应的调用参数。相对地,MCP 则扮演着基础设施协议的角色,它在工程层面解决了工具与模型如何连接的问题,通过标准化的方式来描述和调用工具。
我们可以用一个简单的类比来理解:Function Calling 相当于你学会了“如何打电话”这项技能,包括何时拨号、如何与对方沟通、何时挂断。而 MCP 则是那个全球统一的“电话通信标准”,确保了任何一部电话都能顺利地拨通另一部。

Demo:使用MCP协议
首先来看一个使用functioncall的例子,然后在相同的场景下使用MCP作为对比。
"""
功能:
使用 OpenAI Chat Completions 的 function calling / tool calling 实现一个最小 Agent。
场景:
用户问:“请查询北京天气,并把摄氏温度换算成华氏温度,最后给出穿衣建议。”
Agent 会让模型自己决定是否调用工具:
1. get_weather(city):查询演示天气数据
2. celsius_to_fahrenheit(celsius):摄氏温度转华氏温度
"""
# os 用于读取系统环境变量,例如 LLM_MODEL_ID、LLM_API_KEY。
import os
# json 用于把工具入参和工具结果在 Python dict 与 JSON 字符串之间转换。
import json
# typing 中的 Any 表示“任意类型”,Callable 表示“可调用对象”,用于类型标注。
from typing import Any, Callable
# dotenv 用于从项目根目录的 .env 文件读取环境变量。
from dotenv import load_dotenv
# OpenAI 是官方 Python SDK 中的客户端类;很多国产/本地模型服务也兼容这个接口。
from openai import OpenAI
# 加载 .env 文件中的配置;override=False 表示如果系统环境变量已存在,不强行覆盖。
load_dotenv(override=False)
class FunctionCallAgent:
"""
一个最小 function calling Agent。
这个类故意不使用 LangChain/LangGraph,目的是明确 function calling 的底层流程:
1. 把工具 JSON Schema 发给模型;
2. 模型返回 tool_calls;
3. Python 代码真正执行工具;
4. 把工具结果发回模型;
5. 模型生成最终自然语言回答。
"""
def __init__(
self,
model: str | None = None,
apiKey: str | None = None,
baseUrl: str | None = None,
timeout: int | None = None,
) -> None:
"""
初始化 Agent,并创建 OpenAI 兼容客户端。
参数说明:
model: 模型 ID;如果不传,就从环境变量 LLM_MODEL_ID 读取。
apiKey: API Key;如果不传,就从环境变量 LLM_API_KEY 读取。
baseUrl: OpenAI 兼容服务地址;如果不传,就从环境变量 LLM_BASE_URL 读取。
timeout: 超时时间;如果不传,就从环境变量 LLM_TIMEOUT 读取,默认 60 秒。
"""
# self.model 保存模型名称;or 表示左边为空时使用右边。
self.model = model or os.getenv("LLM_MODEL_ID")
# apiKey 保存 API 密钥;这里变量名沿用你给出的写法。
apiKey = apiKey or os.getenv("LLM_API_KEY")
# baseUrl 保存 OpenAI 兼容接口的基础地址,例如 https://api.openai.com/v1。
baseUrl = baseUrl or os.getenv("LLM_BASE_URL")
# timeout 优先使用参数,其次读取环境变量,最后使用 60 秒。
timeout = timeout or int(os.getenv("LLM_TIMEOUT", 60))
# all([...]) 会检查列表中每个值是否都为真;任何一个为空就说明配置不完整。
if not all([self.model, apiKey, baseUrl]):
# raise ValueError 用于主动抛出参数错误,避免后续请求时才报难懂的错。
raise ValueError("模型ID、API密钥和服务地址必须被提供或在.env文件中定义。")
# 创建 OpenAI 兼容客户端;base_url 允许你接入非 OpenAI 官方但兼容 OpenAI 协议的服务。
self.client = OpenAI(api_key=apiKey, base_url=baseUrl, timeout=timeout)
# 保存 Python 侧真实可执行的工具函数;key 是工具名,value 是函数对象。
# 把工具函数注册到一个字典里,是函数本身,并非可执行的函数结果。
# 因为 function calling 里,模型返回的通常是一个工具名,Agent 需要根据工具名找到真实函数。
# Callable[..., Any]表示一个可以被调用的对象;参数数量和参数类型不限;返回值类型也不限。
self.tool_functions: dict[str, Callable[..., Any]] = {
"get_weather": self.get_weather,
"celsius_to_fahrenheit": self.celsius_to_fahrenheit,
}
# 保存要发给模型看的工具定义;模型只看到 JSON Schema,不会直接看到 Python 函数源码。
self.tools = self.build_openai_tools_schema()
def get_weather(self, city: str) -> dict[str, Any]:
"""
演示用天气查询工具。
参数:
city: 城市名,例如“北京”“上海”“深圳”。
返回:
一个 dict,里面包含城市、天气、摄氏温度、湿度等信息。
注意:
这里不用真实天气 API,是为了让 demo 不依赖外部网络。
真实项目中可以把这里替换成数据库查询、HTTP API、RAG 检索等。
"""
# demo_weather_db 是一个假的天气数据库;key 是城市名,value 是天气信息。
demo_weather_db = {
"北京": {"condition": "晴", "temperature_c": 30.0, "humidity": "35%"},
"上海": {"condition": "小雨", "temperature_c": 27.0, "humidity": "78%"},
"深圳": {"condition": "多云", "temperature_c": 32.0, "humidity": "70%"},
"杭州": {"condition": "阴", "temperature_c": 28.0, "humidity": "65%"},
}
# dict.get(key, default) 表示如果 city 存在就取对应值,否则取 default 默认值。
weather = demo_weather_db.get(
city,
{"condition": "未知", "temperature_c": 26.0, "humidity": "未知"},
)
# 返回结构化结果;模型后续会根据这个 JSON 内容组织最终回答。
return {
"city": city,
"condition": weather["condition"],
"temperature_c": weather["temperature_c"],
"humidity": weather["humidity"],
"source": "demo_local_weather_db",
}
def celsius_to_fahrenheit(self, celsius: float) -> dict[str, float]:
"""
摄氏温度转华氏温度工具。
参数:
celsius: 摄氏温度,例如 30.0。
返回:
一个 dict,包含摄氏温度和换算后的华氏温度。
"""
# 摄氏转华氏公式:F = C * 9 / 5 + 32。
fahrenheit = celsius * 9 / 5 + 32
# round(x, 2) 表示保留两位小数,便于最终回答展示。
return {"celsius": celsius, "fahrenheit": round(fahrenheit, 2)}
def build_openai_tools_schema(self) -> list[dict[str, Any]]:
"""
构造 OpenAI tools 参数需要的 JSON Schema。
function calling 的关键点:
- Python 函数不能直接发给模型;
- 要把函数名、函数说明、参数结构写成 JSON Schema;
- 模型根据这个 schema 判断何时调用哪个工具,以及生成什么参数。
"""
# 返回一个列表;每个元素都是一个工具定义。
return [
{
# type=function 表示这是一个传统函数工具。
"type": "function",
# function 字段描述具体函数的名字、用途、参数格式。
"function": {
# name 必须与 self.tool_functions 中的 key 一致,否则 Python 侧找不到函数。
"name": "get_weather",
# description 会被模型读取,用来判断什么时候应该调用这个工具。
"description": "查询指定城市的演示天气信息,返回天气、摄氏温度和湿度。",
# parameters 是 JSON Schema,描述函数参数。
"parameters": {
# type=object 表示参数整体是一个 JSON 对象。
"type": "object",
# properties 描述对象中的字段。
"properties": {
"city": {
# city 字段类型是字符串。
"type": "string",
# 字段说明会影响模型填参质量。
"description": "城市名,例如:北京、上海、深圳、杭州。",
}
},
# required 表示 city 是必填参数。
"required": ["city"],
},
},
},
{
"type": "function",
"function": {
"name": "celsius_to_fahrenheit",
"description": "把摄氏温度转换为华氏温度。",
"parameters": {
"type": "object",
"properties": {
"celsius": {
# number 表示整数或小数都可以。
"type": "number",
"description": "摄氏温度数值,例如 30.0。",
}
},
"required": ["celsius"],
},
},
},
]
def build_messages(self, user_question: str) -> list[dict[str, Any]]:
"""
构造对话消息。
参数:
user_question: 用户输入的问题。
返回:
messages 列表,符合 OpenAI Chat Completions 的消息格式。
"""
# system prompt 定义 Agent 行为边界:需要数据时必须调用工具,不能瞎编。
system_prompt = (
"你是一个天气助手 Agent。"
"当用户需要天气或温度换算时,你必须优先调用可用工具获取数据,"
"不要自己编造天气和温度。最后用中文给出简洁回答。"
)
# messages 是发送给模型的上下文;role=system 是系统指令,role=user 是用户问题。
return [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_question},
]
def call_llm(self, messages: list[dict[str, Any]]) -> Any:
"""
调用大模型。
参数:
messages: 当前完整对话上下文。
返回:
模型返回的 assistant message,可能包含 content,也可能包含 tool_calls。
"""
# self.client.chat.completions.create 是 Chat Completions API 的创建方法。
response = self.client.chat.completions.create(
# model 指定使用哪个模型。
model=self.model,
# messages 是当前对话历史。
messages=messages,
# tools 把可用工具的 schema 提供给模型。
tools=self.tools,
# tool_choice="auto" 表示让模型自己决定是否调用工具。
tool_choice="auto",
)
# choices[0].message 是第一条候选回答;一般 demo 只取第一条。
return response.choices[0].message
def run_tool_call(self, tool_call: Any) -> str:
"""
执行模型请求的某一次工具调用。
参数:
tool_call: 模型返回的工具调用对象,里面包含函数名和 JSON 字符串参数。
返回:
工具执行结果的 JSON 字符串,用于作为 role=tool 的消息发回模型。
"""
# tool_call.function.name 是模型想调用的函数名。
tool_name = tool_call.function.name
# tool_call.function.arguments 是 JSON 字符串,例如 '{"city":"北京"}'。
raw_arguments = tool_call.function.arguments
# json.loads 把 JSON 字符串转成 Python dict。
arguments = json.loads(raw_arguments)
# 根据函数名从工具注册表中取出真正的 Python 函数。
tool_function = self.tool_functions.get(tool_name)
# 如果模型调用了不存在的工具,返回错误信息,而不是让程序崩溃。
if tool_function is None:
return json.dumps({"error": f"未知工具:{tool_name}"}, ensure_ascii=False)
# **arguments 表示把 dict 展开成关键字参数,例如 {"city":"北京"} -> city="北京"。
result = tool_function(**arguments)
# json.dumps 把 Python dict 转成 JSON 字符串;ensure_ascii=False 保证中文不变成 Unicode 编码。
return json.dumps(result, ensure_ascii=False)
def run(self, user_question: str) -> str:
"""
运行完整 Agent 流程。
参数:
user_question: 用户问题。
返回:
模型最终生成的中文回答。
"""
# 第一步:构造初始 messages。
messages = self.build_messages(user_question)
# 最多循环 5 轮,防止模型反复调用工具导致死循环。
for _ in range(5):
# 第二步:把当前 messages 和工具 schema 发给模型。
assistant_message = self.call_llm(messages)
# 第三步:把 assistant 消息加入对话历史;model_dump 可把 SDK 对象转成普通 dict。
messages.append(assistant_message.model_dump(exclude_none=True))
# 第四步:如果没有 tool_calls,说明模型已经给出最终回答。
if not assistant_message.tool_calls:
# assistant_message.content 就是最终自然语言文本。
return assistant_message.content or ""
# 第五步:模型可能一次请求多个工具调用,所以这里逐个执行。
for tool_call in assistant_message.tool_calls:
# 真正执行 Python 工具函数,得到 JSON 字符串结果。
tool_result_json = self.run_tool_call(tool_call)
# 把工具结果作为 role=tool 的消息追加回上下文。
messages.append(
{
# role=tool 表示这条消息是工具执行结果,不是用户或模型说的话。
"role": "tool",
# tool_call_id 用于告诉模型:这个结果对应刚才哪一次工具调用。
"tool_call_id": tool_call.id,
# content 是工具返回内容,通常写 JSON 字符串。
"content": tool_result_json,
}
)
# 如果循环 5 次仍没有最终回答,返回一个保护性提示。
return "工具调用轮数过多,Agent 已停止。"
# Python 约定:只有直接运行本文件时,__name__ 才等于 "__main__"。
if __name__ == "__main__":
# 实例化 Agent;不传参数时,会自动读取 .env。
agent = FunctionCallAgent()
# 准备一个 demo 问题;你可以改成 input("请输入问题:") 做成命令行交互。
question = "请查询北京天气,并把摄氏温度换算成华氏温度,最后给出穿衣建议。"
# 运行 Agent,拿到最终回答。
answer = agent.run(question)
# 打印最终回答。
print("\n===== Function Calling Agent 最终回答 =====")
print(answer)
===== Function Calling Agent 最终回答 =====
北京天气:晴,30°C,湿度 35%。
换算成华氏温度:86°F。
穿衣建议:天气较热,建议穿短袖、薄款透气衣物,注意防晒,多补水;如果长时间在户外,可以带帽子或太阳镜。
以下是相同的任务用LangChain封装的写法:
"""
功能:
使用 LangChain 框架 + @tool 装饰器实现一个最小 Agent。
场景:
用户问:“请查询北京天气,并把摄氏温度换算成华氏温度,最后给出穿衣建议。”
Agent 会让模型自己决定是否调用工具:
1. get_weather(city):查询演示天气数据
2. celsius_to_fahrenheit(celsius):摄氏温度转华氏温度
安装依赖:
pip install langchain langchain-openai python-dotenv
.env 示例:
LLM_MODEL_ID=你的模型名
LLM_API_KEY=你的API密钥
LLM_BASE_URL=你的OpenAI兼容接口地址
LLM_TIMEOUT=60
"""
# os 用于读取系统环境变量,例如 LLM_MODEL_ID、LLM_API_KEY、LLM_BASE_URL。
import os
# json 用于把工具调用参数、工具返回结果在 Python 对象和 JSON 字符串之间转换。
import json
# Any 表示任意类型,用于类型注解。
from typing import Any
# dotenv 用于从当前项目目录下的 .env 文件中加载环境变量。
from dotenv import load_dotenv
# ChatOpenAI 是 LangChain 对 OpenAI Chat Model 的封装。
# 它不仅支持 OpenAI 官方接口,也支持很多 OpenAI-compatible 的模型服务。
from langchain_openai import ChatOpenAI
# @tool 是 LangChain 提供的工具注册装饰器。
# 用 @tool 包裹普通 Python 函数后,这个函数就会变成 LangChain Tool。
from langchain_core.tools import tool
# SystemMessage 表示系统提示词。
# HumanMessage 表示用户消息。
# ToolMessage 表示工具执行结果消息。
from langchain_core.messages import SystemMessage, HumanMessage, ToolMessage
# load_dotenv 会读取 .env 文件,并把里面的配置加载到 os.environ 中。
# override=False 表示如果系统环境变量中已经存在同名变量,就不覆盖。
load_dotenv(override=False)
@tool
def get_weather(city: str) -> dict[str, Any]:
"""
查询指定城市的演示天气信息,返回天气、摄氏温度和湿度。
Args:
city: 城市名,例如:北京、上海、深圳、杭州。
"""
# demo_weather_db 是一个假的本地天气数据库。
# key 是城市名,value 是该城市的天气信息。
demo_weather_db = {
"北京": {"condition": "晴", "temperature_c": 30.0, "humidity": "35%"},
"上海": {"condition": "小雨", "temperature_c": 27.0, "humidity": "78%"},
"深圳": {"condition": "多云", "temperature_c": 32.0, "humidity": "70%"},
"杭州": {"condition": "阴", "temperature_c": 28.0, "humidity": "65%"},
}
# dict.get(key, default) 表示:
# 如果 city 在 demo_weather_db 中存在,就返回对应天气;
# 如果不存在,就返回后面的默认天气。
weather = demo_weather_db.get(
city,
{"condition": "未知", "temperature_c": 26.0, "humidity": "未知"},
)
# 返回结构化结果。
# LangChain 会把这个结果作为工具执行结果,再交还给模型。
return {
"city": city,
"condition": weather["condition"],
"temperature_c": weather["temperature_c"],
"humidity": weather["humidity"],
"source": "demo_local_weather_db",
}
@tool
def celsius_to_fahrenheit(celsius: float) -> dict[str, float]:
"""
把摄氏温度转换为华氏温度。
Args:
celsius: 摄氏温度数值,例如 30.0。
"""
# 摄氏转华氏公式:
# F = C * 9 / 5 + 32
fahrenheit = celsius * 9 / 5 + 32
# round(fahrenheit, 2) 表示把结果保留两位小数。
return {
"celsius": celsius,
"fahrenheit": round(fahrenheit, 2),
}
class LangChainToolAgent:
"""
一个最小 LangChain Tool Calling Agent。
和之前的 function calling 版本相比:
原来的写法:
1. 手写 tools JSON Schema;
2. 手写 self.tool_functions 字典;
3. 手动根据 tool name 找 Python 函数;
4. 手动 json.loads 解析参数;
5. 手动 tool_function(**arguments) 调用函数。
LangChain 写法:
1. 用 @tool 注册工具;
2. LangChain 根据函数名、类型注解、docstring 生成工具 schema;
3. 用 llm.bind_tools(tools) 把工具绑定到模型;
4. 模型返回 tool_calls;
5. 根据 tool name 找到对应 LangChain Tool;
6. 用 selected_tool.invoke(args) 执行工具。
"""
def __init__(
self,
model: str | None = None,
apiKey: str | None = None,
baseUrl: str | None = None,
timeout: int | None = None,
) -> None:
"""
初始化 Agent,并创建 LangChain 的 ChatOpenAI 客户端。
参数说明:
model: 模型 ID;如果不传,就从环境变量 LLM_MODEL_ID 读取。
apiKey: API Key;如果不传,就从环境变量 LLM_API_KEY 读取。
baseUrl: OpenAI 兼容服务地址;如果不传,就从环境变量 LLM_BASE_URL 读取。
timeout: 超时时间;如果不传,就从环境变量 LLM_TIMEOUT 读取,默认 60 秒。
"""
# self.model 保存模型名称。
# 如果构造函数传入了 model,就用传入值;
# 否则读取环境变量 LLM_MODEL_ID。
self.model = model or os.getenv("LLM_MODEL_ID")
# apiKey 保存 API 密钥。
# 如果构造函数传入了 apiKey,就用传入值;
# 否则读取环境变量 LLM_API_KEY。
apiKey = apiKey or os.getenv("LLM_API_KEY")
# baseUrl 保存 OpenAI-compatible 接口地址。
# 例如:
# https://api.openai.com/v1
# http://localhost:8000/v1
baseUrl = baseUrl or os.getenv("LLM_BASE_URL")
# timeout 保存请求超时时间。
# 优先使用构造函数参数;
# 其次读取环境变量 LLM_TIMEOUT;
# 如果都没有,就默认 60 秒。
timeout = timeout or int(os.getenv("LLM_TIMEOUT", 60))
# all([...]) 会判断列表里的每个元素是否都为真。
# 如果 self.model、apiKey、baseUrl 任意一个为空,就说明配置不完整。
if not all([self.model, apiKey, baseUrl]):
# 主动抛出 ValueError,避免后面请求模型时才出现更难排查的错误。
raise ValueError("模型ID、API密钥和服务地址必须被提供或在.env文件中定义。")
# 创建 LangChain ChatOpenAI 模型对象。
# model 指定模型名称。
# api_key 指定 API 密钥。
# base_url 指定 OpenAI-compatible 接口地址。
# timeout 指定请求超时时间。
self.llm = ChatOpenAI(
model=self.model,
api_key=apiKey,
base_url=baseUrl,
timeout=timeout,
)
# self.tools 保存所有 LangChain 工具对象。
# 注意:
# get_weather 和 celsius_to_fahrenheit 已经不是普通函数,
# 因为它们上面加了 @tool;
# 它们现在是 LangChain Tool 对象。
self.tools = [
get_weather,
celsius_to_fahrenheit,
]
# tool_map 是一个工具名字到工具对象的映射表。
# key 是工具名,例如 "get_weather";
# value 是 LangChain Tool 对象。
#
# 这和原来 self.tool_functions 的作用类似,
# 但这里保存的是 LangChain Tool,而不是原始 Python 函数。
self.tool_map = {
single_tool.name: single_tool
for single_tool in self.tools
}
# bind_tools 会把工具绑定到模型上。
# 绑定后,模型在 invoke 时就能“看到”这些工具的 schema。
#
# LangChain 会根据 @tool 函数的:
# 1. 函数名;
# 2. 参数类型注解;
# 3. docstring;
# 自动生成工具 schema。
self.llm_with_tools = self.llm.bind_tools(self.tools)
def build_messages(self, user_question: str) -> list[Any]:
"""
构造 LangChain 消息列表。
参数:
user_question: 用户输入的问题。
返回:
一个 LangChain Message 列表。
"""
# system_prompt 是系统提示词。
# 它告诉模型这个 Agent 的行为规则。
system_prompt = (
"你是一个天气助手 Agent。"
"当用户需要天气或温度换算时,你必须优先调用可用工具获取数据,"
"不要自己编造天气和温度。最后用中文给出简洁回答。"
)
# 返回消息列表。
# SystemMessage 对应 role=system。
# HumanMessage 对应 role=user。
return [
SystemMessage(content=system_prompt),
HumanMessage(content=user_question),
]
def print_tools_info(self) -> None:
"""
打印当前 Agent 可用的工具信息。
这一步不是 Agent 必须的,
只是为了更直观看到 @tool 注册后,LangChain 保存了哪些工具信息。
"""
# 打印标题。
print("\n===== LangChain 已注册工具 =====")
# 遍历 self.tools 中的每一个工具对象。
for single_tool in self.tools:
# single_tool.name 是工具名。
print(f"工具名: {single_tool.name}")
# single_tool.description 是工具描述。
# 这个描述通常来自函数 docstring。
print(f"工具说明: {single_tool.description}")
# single_tool.args 是工具参数 schema。
# LangChain 根据函数参数类型注解自动生成。
print(f"工具参数: {single_tool.args}")
# 打印分隔线。
print("-" * 40)
def run_one_tool_call(self, tool_call: dict[str, Any]) -> ToolMessage:
"""
执行模型返回的一次工具调用。
参数:
tool_call: 模型返回的工具调用信息。
LangChain 中通常是一个 dict,
里面包含 name、args、id 等字段。
返回:
ToolMessage,表示工具执行结果消息。
"""
# tool_call["name"] 是模型想要调用的工具名。
tool_name = tool_call["name"]
# tool_call["args"] 是模型为工具生成的参数。
# 例如:
# {"city": "北京"}
# {"celsius": 30.0}
tool_args = tool_call["args"]
# tool_call["id"] 是这次工具调用的唯一 ID。
# ToolMessage 需要带上这个 ID,
# 这样模型才能知道工具结果对应刚才哪一次 tool call。
# tool_call["id"] 是这一次工具调用的 ID,不是工具函数本身的 ID。
tool_call_id = tool_call["id"]
# 根据工具名,从 tool_map 中找到对应的 LangChain Tool。
selected_tool = self.tool_map.get(tool_name)
# 如果模型调用了不存在的工具,就构造一个错误结果。
if selected_tool is None:
# 把错误信息转成 JSON 字符串。
error_content = json.dumps(
{"error": f"未知工具:{tool_name}"},
ensure_ascii=False,
)
# 返回 ToolMessage。
return ToolMessage(
content=error_content,
tool_call_id=tool_call_id,
)
# selected_tool.invoke(tool_args) 会真正执行工具。
# 对于 get_weather,相当于执行:
# get_weather.invoke({"city": "北京"})
#
# 对于 celsius_to_fahrenheit,相当于执行:
# celsius_to_fahrenheit.invoke({"celsius": 30.0})
result = selected_tool.invoke(tool_args)
# 工具结果可能是 dict,也可能是 str、int、float 等。
# 为了统一传回模型,这里转成 JSON 字符串。
result_json = json.dumps(result, ensure_ascii=False)
# 构造 ToolMessage。
# content 是工具执行结果。
# tool_call_id 用于对应模型刚才的工具调用 ID。
return ToolMessage(
content=result_json,
tool_call_id=tool_call_id,
)
def run(self, user_question: str) -> str:
"""
运行完整 Agent 流程。
参数:
user_question: 用户问题。
返回:
模型最终生成的中文回答。
"""
# 第一步:构造初始消息。
messages = self.build_messages(user_question)
# 最多循环 5 轮。
# 这是为了防止模型一直调用工具,导致死循环。
for _ in range(5):
# 第二步:调用绑定了工具的模型。
# 如果模型认为需要工具,它会返回 tool_calls。
# 如果模型认为不需要工具,它会直接返回 content。
assistant_message = self.llm_with_tools.invoke(messages)
# 第三步:把模型消息加入历史。
# 这一步非常重要:
# 后续工具结果必须接在这个 assistant_message 后面,
# 模型才能理解上下文。
messages.append(assistant_message)
# 第四步:检查模型是否请求了工具调用。
# 在 LangChain 中,assistant_message.tool_calls 通常是一个 list。
# 如果为空,说明模型已经给出最终回答。
if not assistant_message.tool_calls:
# assistant_message.content 就是模型最终回答。
return str(assistant_message.content or "")
# 第五步:模型可能一次性请求多个工具调用,所以这里逐个执行。
for tool_call in assistant_message.tool_calls:
# 执行单个工具调用,并得到 ToolMessage。
tool_message = self.run_one_tool_call(tool_call)
# 把工具结果加入消息历史。
# 下一轮模型调用时,模型会看到工具结果,并决定继续调用工具或生成最终回答。
messages.append(tool_message)
# 如果 5 轮后仍然没有得到最终回答,就返回保护性提示。
return "工具调用轮数过多,Agent 已停止。"
if __name__ == "__main__":
# 创建 Agent 实例。
# 不传参数时,会自动从 .env 文件或系统环境变量读取配置。
agent = LangChainToolAgent()
# 打印 @tool 注册后的工具信息。
# 这一步方便观察 LangChain 根据函数注解和 docstring 生成了什么工具信息。
agent.print_tools_info()
# 准备一个 demo 问题。
question = "请查询北京天气,并把摄氏温度换算成华氏温度,最后给出穿衣建议。"
# 运行 Agent,得到最终回答。
answer = agent.run(question)
# 打印最终回答。
print("\n===== LangChain Tool Calling Agent 最终回答 =====")
print(answer)
===== LangChain 已注册工具 =====
工具名: get_weather
工具说明: 查询指定城市的演示天气信息,返回天气、摄氏温度和湿度。
Args:
city: 城市名,例如:北京、上海、深圳、杭州。
工具参数: {'city': {'title': 'City', 'type': 'string'}}
----------------------------------------
工具名: celsius_to_fahrenheit
工具说明: 把摄氏温度转换为华氏温度。
Args:
celsius: 摄氏温度数值,例如 30.0。
工具参数: {'celsius': {'title': 'Celsius', 'type': 'number'}}
----------------------------------------
===== LangChain Tool Calling Agent 最终回答 =====
北京天气:晴,30°C,湿度35%
换算后:86°F
穿衣建议:天气偏热,建议穿轻薄短袖、透气衣物,注意防晒,外出可带水;如果长时间在户外,建议备一件薄外套防空调。
以下是使用MCP连接工具服务器的代码(把server和client放进了同一个文件,其实应该分开更直观):
"""
功能:
使用 MCP(Model Context Protocol)实现与 function_call_agent.py 相同的 Agent 功能。
场景:
用户问:“请查询北京天气,并把摄氏温度换算成华氏温度,最后给出穿衣建议。”
Agent 通过 MCP 连接工具服务器,发现工具,再让模型选择并调用工具。
为什么这个文件既是 MCP server 又是 MCP client:
本文件使用命令行参数区分两种模式:
python mcp_agent.py --server 启动 MCP 工具服务器
python mcp_agent.py 启动 MCP Agent 客户端,并自动用子进程拉起 server
注意:
MCP 负责“工具服务器协议化、工具发现、工具调用”。
LLM 仍然负责“根据用户问题决定调用哪个工具”。
"""
# asyncio 用于运行异步 MCP client,因为 MCP 的连接和调用都是异步接口。
import asyncio
# os 用于读取 .env 或系统环境变量。
import os
# sys 用于读取命令行参数,也用于拿到当前 Python 解释器路径。
import sys
# json 用于解析模型生成的工具参数,以及序列化工具结果。
import json
# typing.Any 表示任意类型,主要用于函数返回值和消息结构的类型标注。
from typing import Any
# dotenv 用于读取 .env 文件中的 LLM_MODEL_ID、LLM_API_KEY、LLM_BASE_URL 等配置。
from dotenv import load_dotenv
# OpenAI 是 OpenAI Python SDK 客户端,也可连接 OpenAI 兼容服务。
from openai import OpenAI
# ClientSession 是 MCP 客户端会话对象;StdioServerParameters 描述如何通过 stdio 启动/连接 MCP server。
from mcp import ClientSession, StdioServerParameters, types
# stdio_client 是 MCP 的 stdio 传输客户端;它会启动子进程并通过标准输入输出通信。
from mcp.client.stdio import stdio_client
# FastMCP 是 MCP Python SDK 提供的快速 server 开发封装,可用装饰器暴露 tools/resources/prompts。
from mcp.server.fastmcp import FastMCP
# 用了 官方 MCP Python SDK 里内置的 FastMCP
# 加载 .env 文件;override=False 表示不覆盖已经存在的系统环境变量。
load_dotenv(override=False)
# 创建 MCP server 对象;名字 WeatherMCPDemo 会出现在 MCP 客户端或 Inspector 中。
# json_response=True 让工具更倾向于返回结构化 JSON,便于客户端解析。
mcp = FastMCP("WeatherMCPDemo", json_response=True)
@mcp.tool()
def get_weather(city: str) -> dict[str, Any]:
"""
MCP 工具:查询指定城市的演示天气信息。
参数:
city: 城市名,例如“北京”“上海”“深圳”“杭州”。
返回:
dict 格式的天气信息。
说明:
@mcp.tool() 会把这个普通 Python 函数注册为 MCP 工具。
MCP server 会根据函数签名 city: str 和 docstring 自动生成工具 schema。
"""
# 这里使用本地假数据,避免 demo 依赖真实天气 API。
demo_weather_db = {
"北京": {"condition": "晴", "temperature_c": 30.0, "humidity": "35%"},
"上海": {"condition": "小雨", "temperature_c": 27.0, "humidity": "78%"},
"深圳": {"condition": "多云", "temperature_c": 32.0, "humidity": "70%"},
"杭州": {"condition": "阴", "temperature_c": 28.0, "humidity": "65%"},
}
# 如果 city 不在假数据库中,就返回一个默认天气,保证 demo 不会报 KeyError。
weather = demo_weather_db.get(
city,
{"condition": "未知", "temperature_c": 26.0, "humidity": "未知"},
)
# 返回结构化 dict;MCP 会把它包装成 CallToolResult 返回给 client。
return {
"city": city,
"condition": weather["condition"],
"temperature_c": weather["temperature_c"],
"humidity": weather["humidity"],
"source": "demo_mcp_weather_server",
}
@mcp.tool()
def celsius_to_fahrenheit(celsius: float) -> dict[str, float]:
"""
MCP 工具:把摄氏温度转换为华氏温度。
参数:
celsius: 摄氏温度。
返回:
dict,包含原摄氏温度和华氏温度。
"""
# 摄氏转华氏公式:F = C * 9 / 5 + 32。
fahrenheit = celsius * 9 / 5 + 32
# 返回结构化结果;round(..., 2) 用于保留两位小数。
return {"celsius": celsius, "fahrenheit": round(fahrenheit, 2)}
@mcp.prompt()
def weather_agent_prompt(user_question: str) -> str:
"""
MCP Prompt:返回一个可复用的天气 Agent 提示模板。
参数:
user_question: 用户原始问题。
返回:
一段提示词文本。
说明:
@mcp.prompt() 和 @mcp.tool() 类似,但它暴露的不是可执行函数,
而是“提示模板”。MCP client 可以先发现 prompt,再按需获取 prompt 内容。
"""
# 这里返回系统提示词;client 会把它放进 OpenAI messages 的 system 位置。
return (
"你是一个天气助手 Agent。"
"你可以使用 MCP server 提供的工具查询天气和换算温度。"
"当用户需要天气或温度换算时,必须优先调用工具,不要编造数据。"
"最后用中文回答,并给出简洁穿衣建议。\n\n"
f"用户问题:{user_question}"
)
class MCPAgent:
"""
一个最小 MCP Agent。
它和 function calling 版的核心区别:
- function calling 版:工具函数和工具 schema 都写在 Agent 代码内部;
- MCP 版:工具函数放在 MCP server 中,Agent 先连接 server,再动态发现工具。
也就是说,MCP 把“工具提供者”和“Agent 使用者”解耦了。
"""
def __init__(
self,
model: str | None = None,
apiKey: str | None = None,
baseUrl: str | None = None,
timeout: int | None = None,
) -> None:
"""
初始化 MCP Agent,并创建 OpenAI 兼容客户端。
参数说明和 function_call_agent.py 完全一致。
"""
# 读取模型 ID;优先使用参数,其次使用环境变量。
self.model = model or os.getenv("LLM_MODEL_ID")
# 读取 API Key;变量名 apiKey 沿用你提供的写法。
apiKey = apiKey or os.getenv("LLM_API_KEY")
# 读取 OpenAI 兼容服务地址。
baseUrl = baseUrl or os.getenv("LLM_BASE_URL")
# 读取超时时间;没有配置时默认 60 秒。
timeout = timeout or int(os.getenv("LLM_TIMEOUT", 60))
# 验证必要参数,避免请求时才报错。
if not all([self.model, apiKey, baseUrl]):
raise ValueError("模型ID、API密钥和服务地址必须被提供或在.env文件中定义。")
# 创建 OpenAI 兼容客户端;后续仍用它来驱动模型决策。
self.client = OpenAI(api_key=apiKey, base_url=baseUrl, timeout=timeout)
def build_server_params(self) -> StdioServerParameters:
"""
构造 MCP stdio server 启动参数。
返回:
StdioServerParameters 对象,告诉 MCP client 如何启动 server。
"""
# sys.executable 是当前 Python 解释器路径,保证 client 和 server 使用同一个虚拟环境。
python_executable = sys.executable
# __file__ 是当前这个 mcp_agent.py 文件路径。
current_file = __file__
# StdioServerParameters 描述一个通过标准输入/输出通信的 MCP server 子进程。
return StdioServerParameters(
# command 是要执行的命令,这里就是当前 Python 解释器。
command=python_executable,
# args 是命令参数;相当于运行:python mcp_agent.py --server。
args=[current_file, "--server"],
# env 把当前环境变量传给子进程,保证 server 也能读取必要配置。
env=os.environ.copy(),
)
async def print_mcp_capabilities(self, session: ClientSession) -> None:
"""
打印 MCP server 暴露的 prompts 和 tools。
参数:
session: 已初始化的 MCP ClientSession。
"""
# list_prompts 会向 MCP server 请求可用 prompt 列表。
prompts_response = await session.list_prompts()
# 打印 prompt 列表,方便观察 MCP 的“发现能力”。
print("\n===== MCP Server 可用 Prompts =====")
for prompt in prompts_response.prompts:
# prompt.name 是 prompt 名称;prompt.description 是 docstring 生成的说明。
print(f"- {prompt.name}: {prompt.description}")
# list_tools 会向 MCP server 请求可用 tool 列表。
tools_response = await session.list_tools()
# 打印工具名称、描述和输入 schema,帮助看清 MCP 如何描述工具。
print("\n===== MCP Server 可用 Tools =====")
for tool in tools_response.tools:
print(f"- name: {tool.name}")
print(f" description: {tool.description}")
print(f" inputSchema: {json.dumps(tool.inputSchema, ensure_ascii=False)}")
async def get_prompt_from_mcp(self, session: ClientSession, user_question: str) -> str:
"""
从 MCP server 获取 prompt 模板。
参数:
session: MCP 客户端会话。
user_question: 用户问题。
返回:
prompt 文本,用于放入 OpenAI messages。
"""
# get_prompt 调用 MCP prompt,并传入 prompt 所需参数。
prompt_result = await session.get_prompt(
# 这里的名字必须与 @mcp.prompt() 装饰的函数名一致。
"weather_agent_prompt",
# arguments 是 prompt 参数字典。
arguments={"user_question": user_question},
)
# prompt_result.messages 是 MCP 返回的一组 prompt message。
first_message = prompt_result.messages[0]
# first_message.content 通常是 TextContent,其中 text 字段是真正文本。
content = first_message.content
# isinstance 用于判断 content 是否为 MCP TextContent 类型。
if isinstance(content, types.TextContent):
return content.text
# 兜底:如果 SDK 返回了其他内容类型,就转成字符串。
return str(content)
async def convert_mcp_tools_to_openai_tools(self, session: ClientSession) -> list[dict[str, Any]]:
"""
把 MCP tools 转换成 OpenAI Chat Completions tools 格式。
参数:
session: MCP 客户端会话。
返回:
OpenAI tools 列表。
为什么需要转换:
MCP server 能发现工具,但 OpenAI Chat Completions API 需要的是自己的 tools JSON 格式。
因此 Agent 客户端要做一层适配。
"""
# 从 MCP server 动态获取工具列表。
tools_response = await session.list_tools()
# 准备一个空列表,用来保存转换后的 OpenAI tool schema。
openai_tools: list[dict[str, Any]] = []
# 遍历 MCP server 暴露的每一个工具。
for tool in tools_response.tools:
# MCP v1 中工具入参 schema 字段通常叫 inputSchema。
input_schema = tool.inputSchema
# 按 OpenAI Chat Completions 的格式组织 function tool。
openai_tools.append(
{
"type": "function",
"function": {
# 工具名沿用 MCP tool.name。
"name": tool.name,
# 工具说明沿用 MCP tool.description;如果为空则给空字符串。
"description": tool.description or "",
# 参数 schema 直接使用 MCP 生成的 inputSchema。
"parameters": input_schema,
},
}
)
# 返回可传给 OpenAI API 的工具定义。
return openai_tools
def call_llm(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> Any:
"""
调用 LLM,让模型基于 MCP 工具 schema 决定是否 tool call。
参数:
messages: 对话历史。
tools: 从 MCP tools 转换来的 OpenAI tools。
返回:
assistant message,可能包含自然语言 content,也可能包含 tool_calls。
"""
# 调用 Chat Completions API。
response = self.client.chat.completions.create(
# 指定模型。
model=self.model,
# 传入当前上下文。
messages=messages,
# 传入从 MCP server 动态发现并转换来的工具定义。
tools=tools,
# 让模型自动决定是否调用工具。
tool_choice="auto",
)
# 取第一条候选输出。
return response.choices[0].message
async def call_mcp_tool(self, session: ClientSession, tool_name: str, arguments: dict[str, Any]) -> str:
"""
调用 MCP server 上的某个工具。
参数:
session: MCP 客户端会话。
tool_name: 工具名,例如 get_weather。
arguments: 工具参数,例如 {"city": "北京"}。
返回:
JSON 字符串,用作 OpenAI role=tool 的消息内容。
"""
# session.call_tool 会通过 MCP 协议把工具名和参数发给 server 执行。
result = await session.call_tool(tool_name, arguments=arguments)
# 如果工具执行报错,isError 通常会为 True;这里转成 JSON 返回给模型。
if getattr(result, "isError", False):
return json.dumps({"error": self.extract_mcp_text_result(result)}, ensure_ascii=False)
# MCP v1 的结构化结果字段通常是 structuredContent。
structured_content = getattr(result, "structuredContent", None)
# 如果存在结构化结果,优先返回结构化 JSON。
if structured_content:
return json.dumps(structured_content, ensure_ascii=False)
# 兜底:如果没有 structuredContent,就从 content 中提取文本。
return json.dumps({"text": self.extract_mcp_text_result(result)}, ensure_ascii=False)
def extract_mcp_text_result(self, result: Any) -> str:
"""
从 MCP CallToolResult 中提取文本内容。
参数:
result: MCP 工具调用结果。
返回:
拼接后的文本。
"""
# 准备一个列表保存所有文本片段。
texts: list[str] = []
# result.content 是 MCP 内容块列表,可能包含文本、图片、资源等不同类型。
for block in result.content:
# 如果内容块是 TextContent,就取 block.text。
if isinstance(block, types.TextContent):
texts.append(block.text)
# 如果是其他类型,这里简单转成字符串,真实项目中可按类型分别处理。
else:
texts.append(str(block))
# 用换行拼接多个文本片段。
return "\n".join(texts)
async def run(self, user_question: str) -> str:
"""
运行完整 MCP Agent。
参数:
user_question: 用户问题。
返回:
最终中文回答。
"""
# 构造 stdio server 参数,准备启动 MCP server 子进程。
server_params = self.build_server_params()
# stdio_client 会启动 MCP server 子进程,并返回读写流。
async with stdio_client(server_params) as (read_stream, write_stream):
# ClientSession 封装了 MCP 初始化、工具发现、工具调用等能力。
async with ClientSession(read_stream, write_stream) as session:
# 初始化 MCP 连接;没有这一步,后续 list_tools/call_tool 不能正常工作。
await session.initialize()
# 打印 MCP server 暴露的工具和 prompt,方便你观察 MCP 的发现过程。
await self.print_mcp_capabilities(session)
# 从 MCP server 获取 prompt 模板。
system_prompt = await self.get_prompt_from_mcp(session, user_question)
# 把 MCP tools 转成 OpenAI tools,供模型选择调用。
openai_tools = await self.convert_mcp_tools_to_openai_tools(session)
# 构造初始对话历史。
messages: list[dict[str, Any]] = [
# system 消息放 MCP prompt 返回的提示词。
{"role": "system", "content": system_prompt},
# user 消息放用户原始问题。
{"role": "user", "content": user_question},
]
# 最多循环 5 轮,防止模型无限调用工具。
for _ in range(5):
# 把 messages 和 MCP 转换出的 tools 发给模型。
assistant_message = self.call_llm(messages, openai_tools)
# 将 assistant message 加入上下文,保留模型发起的 tool_calls。
messages.append(assistant_message.model_dump(exclude_none=True))
# 如果没有 tool_calls,说明模型给出了最终回答。
if not assistant_message.tool_calls:
return assistant_message.content or ""
# 遍历模型请求的每个工具调用。
for tool_call in assistant_message.tool_calls:
# 读取模型想调用的工具名。
tool_name = tool_call.function.name
# 解析模型生成的 JSON 参数字符串。
arguments = json.loads(tool_call.function.arguments)
# 打印调用信息,方便你观察“模型决定调用什么工具”。
print("\n===== LLM 请求调用 MCP Tool =====")
print(f"tool_name = {tool_name}")
print(f"arguments = {arguments}")
# 通过 MCP 协议调用 server 上的工具。
tool_result_json = await self.call_mcp_tool(session, tool_name, arguments)
# 打印 MCP 工具返回值,方便调试。
print("===== MCP Tool 返回结果 =====")
print(tool_result_json)
# 把 MCP 工具结果作为 role=tool 消息发回模型。
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": tool_result_json,
}
)
# 如果超过最大轮数,返回保护性提示。
return "工具调用轮数过多,Agent 已停止。"
# 只有直接运行本文件时,才进入这个分支。
if __name__ == "__main__":
# 如果命令行参数中包含 --server,说明当前进程要作为 MCP server 运行。
if "--server" in sys.argv:
# 使用 stdio transport 启动 MCP server;client 会通过标准输入输出与它通信。
mcp.run(transport="stdio")
else:
# 否则当前进程作为 MCP Agent client 运行。
agent = MCPAgent()
# 准备 demo 问题;可以改成 input("请输入问题:")。
question = "请查询北京天气,并把摄氏温度换算成华氏温度,最后给出穿衣建议。"
# asyncio.run 用于运行异步 main 流程。
answer = asyncio.run(agent.run(question))
# 打印最终回答。
print("\n===== MCP Agent 最终回答 =====")
print(answer)
===== MCP Server 可用 Prompts =====
- weather_agent_prompt:
MCP Prompt:返回一个可复用的天气 Agent 提示模板。
参数:
user_question: 用户原始问题。
返回:
一段提示词文本。
说明:
@mcp.prompt() 和 @mcp.tool() 类似,但它暴露的不是可执行函数,
而是“提示模板”。MCP client 可以先发现 prompt,再按需获取 prompt 内容。
===== MCP Server 可用 Tools =====
- name: get_weather
description:
MCP 工具:查询指定城市的演示天气信息。
参数:
city: 城市名,例如“北京”“上海”“深圳”“杭州”。
返回:
dict 格式的天气信息。
说明:
@mcp.tool() 会把这个普通 Python 函数注册为 MCP 工具。
MCP server 会根据函数签名 city: str 和 docstring 自动生成工具 schema。
inputSchema: {"properties": {"city": {"title": "City", "type": "string"}}, "required": ["city"], "title": "get_weatherArguments", "type": "object"}
- name: celsius_to_fahrenheit
description:
MCP 工具:把摄氏温度转换为华氏温度。
参数:
celsius: 摄氏温度。
返回:
dict,包含原摄氏温度和华氏温度。
inputSchema: {"properties": {"celsius": {"title": "Celsius", "type": "number"}}, "required": ["celsius"], "title": "celsius_to_fahrenheitArguments", "type": "object"}
[07/06/26 14:36:57] INFO HTTP Request: POST _client.py:1025
https://your-llm-provider.com/v1/chat/completions
"HTTP/1.1 200 OK"
===== LLM 请求调用 MCP Tool =====
tool_name = get_weather
arguments = {'city': '北京'}
===== MCP Tool 返回结果 =====
{"city": "北京", "condition": "晴", "temperature_c": 30.0, "humidity": "35%", "source": "demo_mcp_weather_server"}
[07/06/26 14:36:58] INFO HTTP Request: POST _client.py:1025
https://your-llm-provider.com/v1/chat/completions
"HTTP/1.1 200 OK"
===== LLM 请求调用 MCP Tool =====
tool_name = celsius_to_fahrenheit
arguments = {'celsius': 30}
===== MCP Tool 返回结果 =====
{"celsius": 30.0, "fahrenheit": 86.0}
[07/06/26 14:37:01] INFO HTTP Request: POST _client.py:1025
https://your-llm-provider.com/v1/chat/completions
"HTTP/1.1 200 OK"
===== MCP Agent 最终回答 =====
北京当前天气:晴,气温 30°C,湿度 35%。
换算成华氏温度:86°F。
穿衣建议:天气较热,建议穿轻薄、透气的夏装,如短袖、短裤/薄长裤;注意防晒,外出可带太阳镜和帽子,并及时补水。
以上流程说明完整链路跑通:
用户问题
↓
LLM 判断需要天气工具
↓
调用 MCP tool: get_weather
↓
拿到北京天气
↓
LLM 判断需要温度换算工具
↓
调用 MCP tool: celsius_to_fahrenheit
↓
拿到华氏温度
↓
LLM 生成最终中文回答
用了MCP后,不再“在 Agent 里手写和维护 tool 的实现与 schema”,而是“MCP server 暴露 tools → Agent 动态发现 tools → bind/传给 LLM → LLM 返回 tool_call → Agent 通过 MCP 调 server”。LLM 仍然需要看到 tool 的 name、description、input schema,否则它无法决定调用哪个工具以及如何填参数。
MCP仍然需要写工具函数,工具函数总得实现,其优势是:工具函数一旦写成 MCP server,之后可以被多个 Agent、多个框架、多个模型客户端复用(直接复用接口,而不需要再自行维护工具)。即项目结构可以是:
weather_mcp_server.py
├── get_weather()
└── celsius_to_fahrenheit()
openai_agent.py
└── 不写 get_weather,只连接 MCP server
langchain_agent.py
└── 不写 get_weather,只连接 MCP server
langgraph_agent.py
└── 不写 get_weather,只连接 MCP server
claude_desktop_config.json
└── 也可以连接同一个 MCP server
而不是
function_call_agent.py
├── get_weather()
└── celsius_to_fahrenheit()
mcp_agent.py
├── get_weather()
└── celsius_to_fahrenheit()
MCP 客户端支持多种连接方式,最常用的是 Stdio 模式(通过标准输入输出与本地进程通信)。连接成功后,第一步通常是查询服务器提供了哪些工具。调用工具时,只需提供工具名称和符合 JSON Schema 的参数。
(个人理解:mcp的作用相当于tool的docker,tool注册在mcp server上并对外提供接口,agent通过其暴露在client端的接口来访问)
MCP 社区生态
MCP 协议的一个巨大优势是丰富的社区生态。Anthropic 和社区开发者已经创建了大量现成的 MCP 服务器,涵盖文件系统、数据库、API 服务等各种场景。这意味着不需要从零开始编写工具适配器,可以直接使用这些经过验证的服务器。
这里给出 MCP 社区的四个资源库:
- Awesome MCP Servers (GitHub - punkpeye/awesome-mcp-servers: A collection of MCP servers. · GitHub)
- 社区维护的 MCP 服务器精选列表
- 包含各种第三方服务器
- 按功能分类,易于查找
- MCP Servers Website (Awesome MCP Servers)
- 官方 MCP 服务器目录网站
- 提供搜索和筛选功能
- 包含使用说明和示例
- Official MCP Servers (GitHub - modelcontextprotocol/servers: Model Context Protocol Servers · GitHub)
- Anthropic 官方维护的服务器
- 质量最高、文档最完善
- 包含常用服务的实现
- Smithery(Smithery - Connect agents to services in minutes)类似于 Python 的 PyPI 或 Node.js 的 npm。通过 Smithery,用户可以:
- 发现和搜索 MCP 服务器
- 一键安装 MCP 服务器
- 查看服务器的使用统计和评价
- 自动获取服务器更新
常用官方 MCP 服务器

社区热门 MCP 服务器

MCP有以下传输方式:

如果是 stdio 模式(上面的Demo样例):通常不需要先手动运行 server,这种方式下client 自动用当前 Python 解释器启动 server.py;如果是 HTTP / SSE / Streamable HTTP 模式(MCP server 是网络服务):需要先启动 server,然后另开一个终端运行 client,这种模式类似普通 Web API。
4.3.3.A2A协议
MCP 协议解决了智能体与工具的交互,而 A2A 协议则解决智能体之间的协作问题。在一个需要多智能体(如研究员、撰写员、编辑)协作的任务中,它们需要通信、委托任务、协商能力和同步状态。
A2A(Agent-to-Agent Protocol)协议由 Google 团队提出,其核心设计理念是实现智能体之间的点对点通信。与 MCP 关注智能体与工具的通信不同,A2A 关注的是智能体之间如何相互协作。这种设计让智能体能够像人类团队一样进行对话、协商和协作。
A2A 的设计哲学是"对等通信"。在 A2A 网络中,每个智能体既是服务提供者,也是服务消费者。智能体可以主动发起请求,也可以响应其他智能体的请求。这种对等的设计避免了中心化协调器的瓶颈,让智能体网络更加灵活和可扩展。


A2A 请求生命周期是一个序列,详细说明了请求遵循的四个主要步骤:代理发现、身份验证、发送消息 API 和发送消息流 API。

由于A2A和ANP的社区目前还不是很完善,并且个人项目用不太到这两个协议(招聘JD中对这两个协议的要求也不多),故这里就不准备Demo了。Hello-Agent教程用他们的框架模拟了这两个协议作为Demo,教程链接。
4.3.4.ANP协议
ANP(Agent Network Protocol)是一个概念性的协议框架,目前由开源社区维护,还没有成熟的生态,其核心设计理念是构建大规模智能体网络的基础设施。如果说 MCP 解决的是"如何访问工具",A2A 解决的是"如何与其他智能体对话",那么 ANP 解决的是"如何在大规模网络中发现和连接智能体"。
ANP 的设计哲学是"去中心化服务发现"。在一个包含成百上千个智能体的网络中,如何让智能体能够找到它需要的服务?ANP 提供了服务注册、发现和路由机制,让智能体能够动态地发现网络中的其他服务,而不需要预先配置所有的连接关系。


4.3.5.三种协议比较

目前MCP的生态相对成熟,不过各种工具的时效性取决于维护者,选择协议的关键在于开发需求本身:
- 如果你的智能体需要访问外部服务(文件、数据库、API),选择MCP
- 如果你需要多个智能体相互协作完成任务,选择A2A
- 如果你要构建大规模的智能体生态系统,考虑ANP
4.4.Agentic-RL
智能体处理更复杂的任务时表现不佳,自然会有疑问:如何让智能体具备更强的推理能力?如何让智能体学会更好地使用工具?如何让智能体能够自我改进?本章引入强化学习训练能力,将从 LLM 训练的基础知识开始,逐步深入到监督微调(Supervised Fine-Tuning,SFT)、群组相对策略优化(Group Relative Policy Optimization, GRPO)等实用技术,最终构建一个完整的智能体训练 pipeline。(该章做为概念学习等辅助作用,不会涉及特别复杂的数学公式,对Hello-Agent中的概念有些许删减)
这部分内容我单开了一篇博客,请看:Agentic RL--大模型 RL 到底在干什么?RLHF、PPO、DPO、GRPO、RLOO、OPD等算法思想、完整链路和相关概念-CSDN博客
4.4.1.从LLM训练到Agentic-RL
强化学习(Reinforcement Learning, RL)是一种专注于解决序贯决策问题的学习范式,它通过智能体与环境的直接交互,在"试错"中学习如何最大化长期收益。
传统的监督学习方法存在三个核心局限:一是数据质量完全决定训练质量,模型只能模仿训练数据,难以超越;二是缺乏探索能力,只能被动学习人类提供的路径;三是难以优化长期目标,无法精确优化多步推理的中间过程。强化学习提供了新的可能性。通过让智能体自主生成多个候选答案并根据正确性获得奖励,它可以学习哪些推理路径更优、哪些步骤是关键,甚至发现比人类标注更好的解题方法。这就是 Agentic RL 的核心思想:将 LLM 作为可学习策略,嵌入智能体的感知-决策-执行循环,通过强化学习优化多步任务表现。
- LLM 是策略:policy: state → action(当前上下文 → 下一步文本 / 下一步工具调用)
- LLM 是可学习的:
- 理论上可以更新 LLM 参数(用 RLHF / PPO / DPO / GRPO 等方法微调 LLM)
- 也可以优化 Agent 的行为策略(用奖励模型优化,检索/反思/多步等策略)
- Agentic RL 是把 Agent 的多步行为放进强化学习框架里优化。
我们可以将多步推理任务(例如数学问题)映射到强化学习框架:
- 智能体:基于 LLM 的推理系统
- 环境:数学问题和验证系统
- 状态:当前的问题描述和已有的推理步骤
- 行动:生成下一步推理或最终答案
- 奖励:答案是否正确(正确+1,错误 0)
一个强大的 LLM(如 GPT、Claude、Qwen)的诞生,通常要经历两个主要阶段:预训练(Pretraining)和后训练(Post-training)。这两个阶段构成了 LLM 从"语言模型"到"对话助手"的完整演化路径。
各个阶段的简要介绍可以看我之前的博客论文串读--OpenAI-GPT系列1.2.3.4--大语言模型标准架构和预训练流程-CSDN博客和论文精读--《Training LMs to follow instructions with human feedback》--InstructGPT:大模型SFT+RLFH(PPO)详解,含代码_instructgpt论文-CSDN博客
Agentic RL核心理念
传统的后训练(我们称之为 PBRFT: Preference-Based Reinforcement Fine-Tuning)主要关注单轮对话的质量优化:给定一个用户问题,模型生成一个回答,然后根据回答的质量获得奖励。这种方式适合优化对话助手,但对于需要多步推理、工具使用、长期规划的智能体任务来说,就显得力不从心了。Agentic RL则是一种新的范式,它将 LLM 视为一个可学习的策略,嵌入在一个顺序决策循环中。在这个框架下,智能体需要在动态环境中与外部世界交互,执行多步行动来完成复杂任务,获得中间反馈来指导后续决策,优化长期累积奖励而非单步奖励。
通过一个具体例子来理解这个区别。在 PBRFT 场景中,用户问"请解释什么是强化学习",模型生成完整回答,然后根据回答质量直接给分。而在 Agentic RL 场景中,用户请求"帮我分析这个 GitHub 仓库的代码质量",智能体需要经历多个步骤:首先调用 GitHub API 获取仓库信息,成功获得仓库结构和文件列表,得到+0.1 的奖励;然后读取主要代码文件,成功获得代码内容,得到+0.1 的奖励;接着分析代码质量合理,得到+0.2 的奖励;最后生成分析报告质量高,得到+0.6 的奖励。总奖励是所有步骤的累积:1.0。可以看到,Agentic RL 的关键特征是多步交互、每一步的行动都会改变环境状态、每一步都可以获得反馈、优化整个任务的完成质量。

PBRFT 思维关注"如何让模型生成更好的单个回答",优化回答质量,关注语言表达,进行单步决策。而 Agentic RL 思维关注"如何让智能体完成复杂任务",优化任务完成度,关注行动策略,进行多步规划。这种转变使得 LLM 从"对话助手"进化为"自主智能体",能够主动寻找信息、知道何时、如何使用外部工具、为了最终目标,愿意执行看似"绕路"的中间步骤、从错误学习。

- 推理(Reasoning)是指从给定信息中逻辑地得出结论的过程,是智能体的核心能力。传统的 CoT 提示方法依赖少样本示例,泛化能力有限;SFT 只能模仿训练数据中的推理模式,难以创新。强化学习的优势在于通过试错学习有效的推理策略,发现训练数据中没有的推理路径,学会何时需要深度思考、何时可以快速回答。推理任务可以建模为序列决策问题,给定问题 q,智能体需要生成推理链 c=(c1,c2,...,cn) 和最终答案 a。奖励函数通常设计为 r(q,c,a)=1 if a=a∗ else 0,训练目标是 maxθEq,(c,a)∼πθ[r(q,c,a)]。通过这种方式,模型学会生成高质量的推理链,而不仅仅是记忆答案。
- 工具使用(Tool Use)是指智能体调用外部工具来完成任务的能力。在工具使用任务中,行动空间扩展为 at∈{atthink,attool},其中 atthink 是生成思考过程,attool=(tool_name,arguments) 是调用工具。强化学习让智能体学会何时需要使用工具、选择哪个工具、如何组合多个工具。例如,在解决数学问题时,智能体需要学会何时使用计算器、何时使用代码解释器、何时直接推理。
- 记忆(Memory)是指智能体保持和重用过去信息的能力,对于长期任务至关重要。LLM 的上下文窗口有限,静态检索策略(如 RAG)无法针对任务优化。强化学习让智能体学会记忆管理策略:决定哪些信息值得记住、何时更新记忆、何时删除过时信息。这类似于人类的工作记忆,我们会主动管理大脑中的信息,保留重要的、遗忘无关的。
- 规划(Planning)是指制定行动序列以达成目标的能力。传统的 CoT 是线性思考,无法回溯;提示工程使用静态规划模板,难以适应新情况。强化学习让智能体学会动态规划:通过试错发现有效的行动序列,学会权衡短期和长期收益。例如,在多步任务中,智能体可能需要先执行一些看似"绕路"的步骤,例如收集信息,才能最终完成任务。
- 自我改进(Self-Improvement)是指智能体回顾自身输出、纠正错误并优化策略的能力。强化学习让智能体学会自我反思:识别自己的错误、分析失败原因、调整策略。这种能力使得智能体能够在没有人工干预的情况下持续改进,类似于人类的"从错误中学习"。
- 感知(Perception)是指理解多模态信息的能力。例如,强化学习可以提升视觉推理能力,让模型学会使用视觉工具,学会视觉规划。这使得智能体不仅能理解文本,还能理解和操作视觉世界。
4.4.2.SFT

在开始强化学习之前,需要先进行 SFT 训练。这是因为预训练模型虽然具备强大的语言能力,但它并不知道如何完成特定任务。预训练模型的训练目标是预测下一个词,而不是解决数学问题或使用工具。预训练模型的输出格式是自由文本,而我们需要结构化的输出(如"Step 1: ..., Step 2: ..., Final Answer: ...")。预训练模型没有见过任务相关的数据,不知道什么是"好的"推理过程。
SFT 的作用是教会模型任务的基本规则。首先,学习输出格式,让模型知道如何组织答案(如使用"Step 1", "Final Answer"等标记)。其次,学习推理模式,通过示例学习如何分解问题、逐步推导。再次,建立基线能力,为后续的强化学习提供一个合理的起点。最后,减少探索空间,强化学习不需要从零开始,可以在 SFT 的基础上优化。
什么时候需要用到强化学习,什么时候SFT就足够了
RL前通常都需要SFT。
- SFT 适合:你知道“标准答案/标准行为”长什么样(教模型“别人是怎么做的”,普通问答、改写)。
- RL 适合:你不容易写出标准答案,但能判断结果好不好(让模型“自己试,做得好就奖励”,多步复杂规划)。
也可以这样判断(来自xhs):
- 大模型能做对,但你想让小模型/私有模型也学会:→ SFT 很合适。
- 大模型偶尔做对,但不稳定:→ 先收集高质量轨迹做 SFT;如果还是不稳定,再考虑 RL。
- 大模型也很难直接做对,但结果可以自动验证:→ 可以考虑 RL 或 search + verifier + RL。
- 大模型能生成很多候选,你能判断哪个更好,但很难写标准答案:→ DPO / RLHF / RL 更合适。
LoRA
直接微调整个模型需要大量的计算资源和显存。对于 Qwen3-0.6B(0.6B 参数),全量微调需要约 12GB 显存(FP16)或 24GB 显存(FP32)。对于更大的模型(如 7B、13B),全量微调几乎不可能在消费级 GPU 上进行。
LoRA(Low-Rank Adaptation)是一种参数高效微调方法,它只训练少量的额外参数,而保持原模型参数冻结。LoRA 的核心思想是:模型微调时的参数变化可以用低秩矩阵表示。具体介绍和公式原理可以看我之前的这篇博客大模型微调、优化与评估(什么是大模型,知识外挂RAG,前缀微调prefix-tuning,低秩适应方法LoRA、QLora,微调方法对比总结,迁移学习,领域适应,评估指标BLEU、ROUGE)_rag评估bleu-CSDN博客

LoRA 的关键超参数包括:
- 秩(rank,r),控制 LoRA 矩阵的秩,越大表达能力越强,但参数量也越多,典型值为 4-64,默认 8;
- Alpha(α),LoRA 的缩放因子,实际更新为 ΔW=α/r*BA,控制 LoRA 的影响强度,典型值等于 rank;
- 目标模块(target_modules),指定哪些层应用 LoRA,通常选择注意力层(q_proj, k_proj, v_proj, o_proj),也可以包括 MLP 层(gate_proj, up_proj, down_proj)。
- 可以把 Transformer 里一层大致看成两块:Attention 部分负责“看上下文里哪些 token 重要”;MLP / FFN 部分:负责“对信息做进一步加工和变换”
q是 query,意思是“我要找什么信息”(比如模型正在生成某个词,它会用q_proj生成一个查询向量:“我现在需要什么上下文信息?”)。k是 key,意思是“我这里有什么信息”。(每个 token 会通过k_proj生成自己的 key:“我这个 token 可以提供什么线索?”)v是 value,意思是“真正要取走的信息”。(v 决定“从被看的 token 里拿什么信息”)o是 output projection。(o_proj 负责把 attention 的结果整理后送回模型主干。)up_proj会把 hidden size 升到更大的中间维度,方便做复杂变换。gate_proj是门控层,它决定哪些信息应该通过、哪些信息应该被抑制。down_proj把扩大的中间维度再压回 hidden size(主干需要的维度)。- LoRA通常就加在这些“大量参与模型能力表达”的线性层上。Attention 决定模型如何利用上下文(第一优先级,尤其q、v)。MLP 更像模型内部的“知识加工器”和“模式变换器”(其次优先级)。
可以采用Llama-Factory现成的框架做微调,它提供web-cli,不用自己写代码只用填入参数。
4.4.3.PPO、DPO、GRPO
在强化学习领域,PPO(Proximal Policy Optimization)是最经典的算法之一。PPO 通过限制策略更新的幅度,保证训练的稳定性。但是,PPO 在 LLM 训练中存在一些问题:需要训练 Value Model(价值模型),增加了训练复杂度和显存占用;需要同时维护四个模型(Policy Model、Reference Model、Value Model、Reward Model),工程实现复杂;训练不稳定,容易出现奖励崩塌或策略退化。
除了 PPO 之外,DPO(Direct Preference Optimization)也是 LLM 对齐训练中常见的方法。DPO 不再显式训练 Reward Model,也不需要像 PPO 那样通过复杂的强化学习循环更新策略,而是直接使用成对偏好数据进行训练,例如给定同一个问题下的较优回答和较差回答,让模型提高生成较优回答的概率、降低生成较差回答的概率。相比 PPO,DPO 的工程实现更简单、训练更稳定,适合偏好数据较充足的场景;但它主要依赖离线偏好样本,缺少 PPO/GRPO 这类方法在在线采样和可验证任务奖励优化方面的灵活性。
GRPO(Group Relative Policy Optimization)是一种简化的 PPO 变体,专门为 LLM 设计。GRPO 的核心思想是:不需要 Value Model,使用组内相对奖励代替绝对奖励;简化训练流程,只需要 Policy Model 和 Reference Model;提高训练稳定性,减少奖励崩塌的风险。
Llama-Factory也提供PPO和DPO微调的功能。
4.5.Skills
4.5.1.什么是Agent Skills
Agent Skills 是一种标准化的程序性知识封装格式。如果说 MCP 为智能体提供了"手"来操作工具,那么 Skills 就提供了"操作手册"或"SOP(标准作业程序)",教导智能体如何正确使用这些工具。
这种设计理念源于一个简单但深刻的洞察:连接性(Connectivity)与能力(Capability)应该分离。MCP 专注于前者,Skills 专注于后者。这种职责分离带来了清晰的架构优势:
- MCP 的职责:提供标准化的访问接口,让智能体能够"够得着"外部世界的数据和工具
- Skills 的职责:提供领域专业知识,告诉智能体在特定场景下"如何组合使用这些工具"
Agent Skills vs MCP:本质区别与协作关系
用一个类比来理解:MCP 像是 USB 接口或驱动程序,它定义了设备如何连接;而 Skills 像是软件应用程序,它定义了如何使用这些连接的设备来完成具体任务。你可以拥有一个功能完善的打印机驱动(MCP),但如果没有告诉你如何在 Word 里设置页边距和双面打印(Skill),你仍然无法高效地完成打印任务。
- MCP:解决“Agent 能不能连接外部工具 / API / 数据源”
- Skills:解决“Agent 知不知道该如何正确使用这些能力”
假设你要构建一个智能体来帮助团队进行代码审查:
MCP 让智能体"能够"访问 GitHub,能够调用这些 API。但它不知道"应该"做什么。
# MCP 提供对 GitHub 的标准化访问
github_mcp = MCPTool(server_command=["npx", "-y", "@modelcontextprotocol/server-github"])
# MCP 暴露的工具(简化示例):
# - list_pull_requests(repo, state)
# - get_pull_request_details(pr_number)
# - list_pr_comments(pr_number)
# - create_pr_comment(pr_number, body)
# - get_file_content(repo, path, ref)
# - list_pr_files(pr_number)
Skills 告诉智能体"应该"做什么、如何组织审查流程、需要关注哪些公司特定的规范。它是领域知识和最佳实践的容器:
---
name: code-review-workflow
description: 执行标准的代码审查流程,包括检查代码风格、安全问题、测试覆盖率等
---
# 代码审查工作流
## 审查清单
当执行代码审查时,按以下步骤进行:
1. **获取 PR 信息**:调用 `get_pull_request_details` 了解变更背景
2. **分析变更文件**:调用 `list_pr_files` 获取文件列表
3. **逐文件审查**:
- 对于 `.py` 文件:检查是否符合 PEP 8,是否有明显的性能问题
- 对于 `.js/.ts` 文件:检查是否有未处理的 Promise,是否使用了废弃的 API
- 对于测试文件:验证是否覆盖了新增的代码路径
4. **安全检查**:
- 是否硬编码了敏感信息(密钥、密码)
- 是否有 SQL 注入或 XSS 风险
5. **提供反馈**:
- 严重问题:使用 `create_pr_comment` 直接评论
- 建议改进:在总结中提出
## 公司特定规范
- 所有数据库查询必须使用参数化查询
- API 端点必须有权限验证装饰器
- 新功能必须附带单元测试(覆盖率 > 80%)
## 示例评论模板
**严重问题**:
⚠️ 安全风险:第 45 行直接拼接 SQL 字符串,存在注入风险。
建议改用参数化查询:`cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))`
在上下文管理策略上也有本质差异:
- Agent Skills 最核心的创新是渐进式披露(Progressive Disclosure)机制。这种机制将技能信息分为三个层次,智能体按需逐步加载,既确保必要时不遗漏细节,又避免一次性将过多内容塞入上下文窗口。
Agent Skills 渐进式披露三层架构:
- 第一层:元数据(Metadata)
- 在 Skills 的设计中,每个技能都存放在一个独立的文件夹中,核心是一个名为
SKILL.md的 Markdown 文件。这个文件必须以 YAML 格式的 Frontmatter 开头,定义技能的基本信息。 - 当智能体启动时,它会扫描所有已安装的技能文件夹,仅读取每个
SKILL.md的 Frontmatter 部分,将这些元数据加载到系统提示词中。根据实测数据,每个技能的元数据仅消耗约 100 个 token。即使你安装了 50 个技能,初始的上下文消耗也只有约 5,000 个 token。 - 这与 MCP 的工作方式形成了鲜明对比。在典型的 MCP 实现中,当客户端连接到一个服务器时,通常会通过
tools/list请求获取所有可用工具的完整 JSON Schema,可能立即消耗数万个 token。
- 在 Skills 的设计中,每个技能都存放在一个独立的文件夹中,核心是一个名为
- 第二层:技能主体(Instructions)
- 当智能体通过分析用户请求,判断某个技能与当前任务高度相关时,它会进入第二层加载。此时,智能体会读取该技能的完整
SKILL.md文件内容,将详细的指令、注意事项、示例等加载到上下文中。 - 此时,智能体获得了完成任务所需的全部上下文:数据库结构、查询模式、注意事项等。这部分内容的 token 消耗取决于指令的复杂度,通常在 1,000 到 5,000 个 token 之间。
- 当智能体通过分析用户请求,判断某个技能与当前任务高度相关时,它会进入第二层加载。此时,智能体会读取该技能的完整
- 第三层:附加资源(Scripts & References)
- 对于更复杂的技能,
SKILL.md可以引用同一文件夹下的其他文件:脚本、配置文件、参考文档等。智能体仅在需要时才加载这些资源。
- 对于更复杂的技能,
这种设计有三个关键优势:
- 无限的知识容量:通过脚本和外部文件,技能可以"携带"远超上下文限制的知识。例如,一个数据分析技能可以附带一个 1GB 的数据文件和一个查询脚本,智能体通过执行脚本来访问数据,而无需将整个数据集加载到上下文中。
- 确定性执行:复杂的计算、数据转换、格式解析等任务交给代码执行,避免了 LLM 生成过程中的不确定性和幻觉问题。
- Token消耗量大幅减少:这种架构不仅大幅降低了初始成本,还使得对话过程中的上下文管理更加精准和高效。
Skills+MCP应该混合使用:
典型工作流:
- 用户问:"分析公司内部谁的话语权最高"
- Skills 层识别这是一个数据分析任务,加载
mysql-employees-analysis技能 - Skills 层根据技能指令,将任务分解为子步骤:查询管理关系、薪资对比、任职时长等
- MCP 层执行具体的 SQL 查询,返回结果
- Skills 层根据技能中的领域知识,解读数据并生成综合分析
- 返回结构化的答案给用户
这种架构的优势是:
- 关注点分离:MCP 专注于"能力",Skills 专注于"智慧"
- 成本优化:渐进式加载大幅降低 token 消耗
- 可维护性:业务逻辑(Skills)与基础设施(MCP)解耦
- 复用性:同一个 MCP 服务器可以被多个 Skills 使用
4.5.2.如何创建和使用 Skills
SKILL.md 文件的标准结构:
---
# === 必需字段 ===
name: skill-name
# 技能的唯一标识符,使用 kebab-case 命名
description: >
简洁但精确的描述,说明:
1. 这个技能做什么
2. 什么时候应该使用它
3. 它的核心价值是什么
# 注意:description 是智能体选择技能的唯一依据,必须写清楚!
# === 可选字段 ===
version: 1.0.0
# 语义化版本号
allowed_tools: [tool1, tool2]
# 此技能可以调用的工具列表(白名单)
required_context: [context_item1]
# 此技能需要的上下文信息
license: MIT
# 许可协议
author: Your Name <email@example.com>
# 作者信息
tags: [database, analysis, sql]
# 便于分类和搜索的标签
---
# 技能标题
## 概述
(对技能的详细介绍,包括使用场景、技术背景等)
## 前置条件
(使用此技能需要的环境配置、依赖项等)
## 工作流程
(详细的步骤说明,告诉智能体如何执行任务)
## 最佳实践
(经验总结、注意事项、常见陷阱等)
## 示例
(具体的使用案例,帮助智能体理解)
## 故障排查
(常见问题和解决方案)
根据 Anthropic 官方文档和社区最佳实践,编写有效的 Skills 需要遵循以下原则:
1. 精准的 Description。description 是智能体决策的关键。它应该:
- 精确定义适用范围:避免模糊的描述如"帮助处理数据"
- 包含触发关键词:让智能体能够匹配用户意图
- 说明独特价值:与其他技能区分开来
- 例如对于数据库查询,不应只写“处理数据库查询”,而要改为“ 将中文业务问题转换为 SQL 查询并分析 MySQL employees 示例数据库。 适用于员工信息查询、薪资统计、部门分析、职位变动历史等场景。 当用户询问关于员工、薪资、部门的数据时使用此技能。”
2. 模块化与单一职责。一个 Skill 应该专注于一个明确的领域或任务类型。如果一个 Skill 试图做太多事情,会导致:
- Description 过于宽泛,匹配精度下降
- 指令内容过长,浪费上下文
- 难以维护和更新
- 例如对于数据库查询,与其创建一个"通用数据分析"技能,不如创建多个专门的技能:
mysql-employees-analysis:专门分析 employees 数据库sales-data-analysis:专门分析销售数据user-behavior-analysis:专门分析用户行为数据
- 例如对于数据库查询,与其创建一个"通用数据分析"技能,不如创建多个专门的技能:
3. 确定性优先原则
- 对于复杂的、需要精确执行的任务,优先使用脚本而不是依赖 LLM 生成。
- 例如,在数据导出场景中,与其让 LLM 生成 Excel 二进制内容(容易出错),不如编写一个专门的脚本来处理这个任务,SKILL.md 中只需要指导智能体何时调用这个脚本即可。
4. 渐进式披露策略。合理利用三层结构,将信息按重要性和使用频率分层:
- SKILL.md 主体:放置核心工作流、常用模式
- 附加文档(如
advanced.md):放置高级用法、边缘情况 - 数据文件:放置大型参考数据,通过脚本按需查询
Demo及Skill的使用流程
Skill的使用流程一般是:
用户输入任务
↓
SkillLoader 读取所有 SKILL.md 的 frontmatter
↓
LLM 根据 description 选择 skill
↓
Harness 加载被选中 skill 的 SKILL.md 正文
↓
Harness 根据 allowed_tools 暴露工具
↓
LLM 判断是否需要调用工具
↓
ToolExecutor 检查工具是否在白名单中
↓
执行工具
↓
工具结果返回给 LLM
↓
LLM 继续推理或输出最终答案
以下的demo是自己想的,只是为了尝试Skills具体的使用,故没有很具体的应用。
包含两个skills:
- web-research:用 Tavily 做联网检索和资料整理。
- structured-writing:用于文本改写、润色、整理。
项目目录
skills_agent_demo/
├── .env
├── requirements.txt
├── main.py
├── agent/
│ ├── __init__.py
│ ├── llm.py
│ ├── skill_loader.py
│ ├── tools.py
│ └── harness.py
└── skills/
├── web-research/
│ ├── SKILL.md
│ ├── advanced.md
│ └── data/
│ └── source_policy.json
└── structured-writing/
├── SKILL.md
└── advanced.md
完整流程图
┌────────────────────┐
│ 用户输入任务 │
└─────────┬──────────┘
↓
┌────────────────────┐
│ SkillLoader │
│ 读取所有 SKILL.md │
│ 的 frontmatter │
└─────────┬──────────┘
↓
┌────────────────────┐
│ Skill Selector LLM │
│ 只看 name + │
│ description │
└─────────┬──────────┘
↓
┌────────────────────┐
│ 选中某个 Skill │
│ 例如 web-research │
└─────────┬──────────┘
↓
┌────────────────────┐
│ Harness 加载该 │
│ Skill 的正文 body │
└─────────┬──────────┘
↓
┌────────────────────┐
│ Harness 读取 │
│ allowed_tools │
│ 并暴露工具 schema │
└─────────┬──────────┘
↓
┌────────────────────┐
│ LLM 根据 Skill │
│ 决定下一步 │
└─────────┬──────────┘
↓
┌────────────────────────────┐
│ 可能 1:直接回答 │
│ 可能 2:调用 tavily_search │
│ 可能 3:读取 advanced.md │
│ 可能 4:查询 data 文件 │
└─────────┬──────────────────┘
↓
┌────────────────────┐
│ ToolExecutor 执行 │
│ 工具并返回结果 │
└─────────┬──────────┘
↓
┌────────────────────┐
│ 工具结果重新放回 │
│ messages │
└─────────┬──────────┘
↓
┌────────────────────┐
│ LLM 基于观察结果 │
│ 继续推理或最终回答 │
└────────────────────┘
Agent/harness.py
from __future__ import annotations
import json
import re
from typing import Any
from agent.llm import ChatLLM
from agent.skill_loader import Skill, SkillLoader
from agent.tools import ToolExecutor
class SkillAgentHarness:
"""
Skills Agent 的工程外壳。
你可以把它理解成:
LLM + Harness = Agent
LLM 负责:
1. 选择 skill。
2. 根据上下文生成回答。
3. 决定是否调用工具。
Harness 负责:
1. 加载 skills。
2. 控制 skill 选择流程。
3. 把选中的 SKILL.md 正文放进上下文。
4. 根据 allowed_tools 暴露工具。
5. 执行工具。
6. 把工具结果塞回消息列表。
7. 控制最大工具调用轮数。
"""
def __init__(
self,
llm: ChatLLM,
skill_loader: SkillLoader,
max_tool_steps: int = 6,
verbose: bool = True,
):
"""
初始化 Agent Harness。
参数:
llm:
ChatLLM 实例。
skill_loader:
SkillLoader 实例。
max_tool_steps:
最大工具调用轮数。
防止模型无限循环调用工具。
verbose:
是否打印中间过程。
"""
self.llm = llm
self.skill_loader = skill_loader
self.tool_executor = ToolExecutor(skill_loader)
self.max_tool_steps = max_tool_steps
self.verbose = verbose
def run(self, user_task: str) -> str:
"""
运行 Agent。
完整流程:
1. 根据用户任务选择 skill。
2. 打印被选中的 skill。
3. 使用被选中的 skill 执行任务。
4. 返回最终答案。
"""
selected_skill, reason = self._select_skill(user_task)
if self.verbose:
print("\n========== Skill Selection ==========")
print(f"Selected skill: {selected_skill.name}")
print(f"Reason: {reason}")
print("Allowed tools:", selected_skill.allowed_tools)
print("Available resources:", self.skill_loader.list_resources(selected_skill.name))
return self._run_with_skill(user_task, selected_skill)
def _select_skill(self, user_task: str) -> tuple[Skill, str]:
"""
选择最合适的 skill。
关键点:
选择阶段只给 LLM 看:
1. skill.name
2. skill.description
不给它看:
1. SKILL.md 正文
2. advanced.md
3. data 文件
这样可以模拟真实 Skills 系统中的第一层渐进式披露。
"""
skill_candidates = self.skill_loader.list_for_selection()
selector_messages = [
{
"role": "system",
"content": (
"你是一个 skill selector。\n"
"你必须只根据每个 skill 的 description 判断应该选择哪个 skill。\n"
"name 只作为返回用的唯一标识,不作为语义判断依据。\n"
"请返回严格 JSON,不要输出多余文字。\n"
"JSON 格式:{\"skill_name\": \"...\", \"reason\": \"...\"}"
),
},
{
"role": "user",
"content": json.dumps(
{
"user_task": user_task,
"skills": skill_candidates,
},
ensure_ascii=False,
indent=2,
),
},
]
# 这里不传 tools,因为 skill 选择只是一个分类任务
response = self.llm.chat(selector_messages, temperature=0.0)
content = response.choices[0].message.content or ""
# 尝试解析模型返回的 JSON
data = self._parse_json_object(content)
skill_name = data.get("skill_name")
reason = data.get("reason", "")
# 如果模型返回了合法 skill name,就使用它
if skill_name in self.skill_loader.skills:
return self.skill_loader.get(skill_name), reason
# 如果模型输出格式错误,使用一个简单 fallback,防止 demo 中断
fallback = self._fallback_select_skill(user_task)
return fallback, "LLM 返回的 skill_name 无效,使用关键词 fallback。"
def _fallback_select_skill(self, user_task: str) -> Skill:
"""
简单的关键词 fallback。
真实工程里可以替换成:
1. embedding 检索
2. BM25
3. 规则系统
4. 小模型分类器
这里保留 fallback 是为了让 demo 更稳。
"""
text = user_task.lower()
# 这些关键词更像联网研究任务
if any(
keyword in text
for keyword in [
"搜索",
"查找",
"最新",
"新闻",
"联网",
"资料",
"来源",
"调研",
"research",
"tavily",
"官网",
]
):
return self.skill_loader.get("web-research")
# 默认使用写作 skill
return self.skill_loader.get("structured-writing")
def _run_with_skill(self, user_task: str, skill: Skill) -> str:
"""
使用指定 skill 处理用户任务。
这个函数是 Agent 的核心 loop。
流程:
1. 读取当前 skill 可用资源列表。
2. 构造 system prompt。
3. 根据 allowed_tools 暴露工具 schema。
4. 调用 LLM。
5. 如果 LLM 返回 tool_calls,就执行工具。
6. 把工具结果追加到 messages。
7. 再次调用 LLM。
8. 如果 LLM 不再调用工具,就返回最终答案。
"""
# 只列出资源路径,不读取资源内容
resources = self.skill_loader.list_resources(skill.name)
# 选中 skill 后,才把 SKILL.md 主体放进上下文
system_prompt = f"""
你是一个支持 Skills 的 Agent。
当前已经触发的 Skill:
# Skill Name
{skill.name}
# Skill Description
{skill.description}
# Skill Body
{skill.body}
# 当前 Skill 允许使用的工具白名单
{json.dumps(skill.allowed_tools, ensure_ascii=False)}
# 当前 Skill 可按需读取的资源
{json.dumps(resources, ensure_ascii=False, indent=2)}
重要规则:
1. 你必须遵循当前 Skill 的工作流程。
2. 你只能调用 allowed_tools 中列出的工具。
3. 你不能假装已经读取 advanced.md 或 data 文件。
4. 如果需要高级策略、边缘情况或数据文件,必须调用 load_skill_resource 或 query_skill_data。
5. 如果工具返回错误,要先根据错误调整下一步,不要编造工具结果。
6. 最终回答使用中文,结构清晰。
""".strip()
messages: list[dict[str, Any]] = [
{
"role": "system",
"content": system_prompt,
},
{
"role": "user",
"content": user_task,
},
]
# 根据当前 skill 的 allowed_tools 获取工具 schema
# 注意:这里不会暴露所有工具,只暴露白名单内工具
tool_schemas = self.tool_executor.schemas_for(skill.allowed_tools)
# 最多循环 max_tool_steps 轮,防止无限调用工具
for step in range(1, self.max_tool_steps + 1):
# 调用 LLM
response = self.llm.chat(
messages=messages,
tools=tool_schemas,
tool_choice="auto" if tool_schemas else None,
temperature=0.2,
)
assistant_message = response.choices[0].message
# 如果模型返回 tool_calls,说明它想调用工具
tool_calls = assistant_message.tool_calls or []
# 如果没有 tool call,说明模型已经准备好最终回答
if not tool_calls:
return assistant_message.content or ""
# 把 assistant 的 tool call 消息追加到 messages
messages.append(assistant_message.model_dump(exclude_none=True))
# 逐个执行工具调用
for tool_call in tool_calls:
tool_name = tool_call.function.name
try:
arguments = json.loads(tool_call.function.arguments or "{}")
except json.JSONDecodeError:
arguments = {}
if self.verbose:
print(f"\n[Tool Step {step}] {tool_name}")
print(json.dumps(arguments, ensure_ascii=False, indent=2))
# 通过 ToolExecutor 执行工具
# ToolExecutor 会再次检查 allowed_tools 白名单
tool_result = self.tool_executor.execute(
skill=skill,
tool_name=tool_name,
arguments=arguments,
)
if self.verbose:
preview = tool_result[:1000]
print("[Tool Result Preview]")
print(preview + ("..." if len(tool_result) > 1000 else ""))
# 把工具结果以 role=tool 的形式加入 messages
# 下一轮 LLM 会基于这个观察结果继续推理
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": tool_result,
}
)
# 如果达到最大工具轮数还没结束,强制让模型总结已有信息
messages.append(
{
"role": "system",
"content": (
"工具调用次数已达到上限。"
"请基于已有信息给出最终答案。"
"如果信息不足,请明确说明不足之处。"
),
}
)
final_response = self.llm.chat(messages=messages, temperature=0.2)
return final_response.choices[0].message.content or ""
def _parse_json_object(self, text: str) -> dict[str, Any]:
"""
从模型输出中解析 JSON 对象。
理想情况下,模型会直接输出:
{"skill_name": "...", "reason": "..."}
但模型偶尔可能加上一些解释文字。
所以这里做两步解析:
1. 先尝试直接 json.loads。
2. 如果失败,再用正则提取第一个 {...}。
"""
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError:
pass
match = re.search(r"\{.*\}", text, flags=re.DOTALL)
if match:
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
pass
return {}
Agent/llm.py
import os
from typing import Any
from openai import OpenAI
class ChatLLM:
"""
OpenAI Chat Completions 的简单封装。
这个类只负责一件事:
调用 OpenAI 的 chat.completions.create 接口。
注意:
这里故意没有使用 LangChain。
这样可以更清楚地看到:
1. LLM 只是负责生成回复或 tool call。
2. Agent 的 skill 选择、tool 执行、循环控制,都是 Harness 在做。
"""
def __init__(self, model: str | None = None):
"""
初始化 LLM 客户端。
参数:
model:
可选模型名。
如果不传,则读取环境变量 LLM_MODEL_ID。
如果环境变量也没有,则默认使用 gpt-4.1-mini。
"""
# 从环境变量中读取 LLM_API_KEY
self.client = OpenAI(api_key=os.getenv("LLM_API_KEY"))
# 优先使用传入模型,其次使用环境变量,最后使用默认模型
self.model = model or os.getenv("LLM_MODEL_ID", "gpt-4.1-mini")
def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
tool_choice: str | dict[str, Any] | None = None,
temperature: float = 0.2,
):
"""
调用 OpenAI Chat Completions。
参数:
messages:
对话消息列表。
格式类似:
[
{"role": "system", "content": "..."},
{"role": "user", "content": "..."}
]
tools:
可选工具 schema 列表。
如果传入 tools,模型就可以返回 tool_calls。
tool_choice:
工具选择策略。
常用值:
- "auto":模型自己决定是否调用工具
- "none":禁止调用工具
- 指定某个工具:强制调用
temperature:
采样温度。
越低越稳定,越高越发散。
返回:
OpenAI SDK 返回的 ChatCompletion 对象。
"""
kwargs: dict[str, Any] = {
"model": self.model,
"messages": messages,
"temperature": temperature,
}
# 只有在存在工具时才传 tools 参数
if tools:
kwargs["tools"] = tools
kwargs["tool_choice"] = tool_choice or "auto"
return self.client.chat.completions.create(**kwargs)
Agent/skill_loader.py
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import json
import yaml
@dataclass
class Skill:
"""
Skill 数据结构。
一个 Skill 对应一个 skills/<skill-name>/SKILL.md 文件。
字段说明:
name:
skill 的唯一标识符。
description:
skill 的简洁描述。
在这个 demo 中,description 是 LLM 选择 skill 的唯一语义依据。
version:
skill 版本号,可选。
allowed_tools:
当前 skill 允许调用的工具白名单。
required_context:
当前 skill 需要的上下文说明。
这个 demo 中暂时只展示,不做强校验。
license:
许可证信息。
author:
作者信息。
tags:
标签,用于分类。
注意:在本 demo 的 skill 选择阶段,不使用 tags 作为语义判断依据。
directory:
skill 所在目录。
body:
SKILL.md 中 frontmatter 后面的正文。
只有 skill 被选中后,才会放进 Agent 上下文。
"""
name: str
description: str
version: str | None
allowed_tools: list[str]
required_context: list[str]
license: str | None
author: str | None
tags: list[str]
directory: Path
body: str
def metadata_for_selection(self) -> dict[str, str]:
"""
返回给 skill selector 的最小信息。
注意:
这里故意只返回 name 和 description。
原因:
1. name 用于标识最终选择哪个 skill。
2. description 是选择 skill 的唯一语义依据。
3. 不把 SKILL.md 正文、advanced.md、data 文件提前塞给模型。
"""
return {
"name": self.name,
"description": self.description,
}
class SkillLoader:
"""
Skill 加载器。
主要职责:
1. 扫描 skills 目录。
2. 读取每个 SKILL.md。
3. 解析 YAML frontmatter。
4. 保存 skill 元信息和正文。
5. 提供资源读取能力,用于渐进式披露。
"""
def __init__(self, skills_root: Path):
"""
初始化 SkillLoader。
参数:
skills_root:
skills 根目录,例如 skills_agent_demo/skills。
"""
self.skills_root = skills_root
# key 是 skill.name,value 是 Skill 对象
self.skills: dict[str, Skill] = {}
def discover(self) -> dict[str, Skill]:
"""
发现所有 skills。
它会扫描:
skills/*/SKILL.md
返回:
skill name 到 Skill 对象的映射。
异常:
如果 skills 目录不存在,会报错。
如果没有发现任何 SKILL.md,会报错。
如果 skill name 重复,会报错。
"""
if not self.skills_root.exists():
raise FileNotFoundError(f"skills 目录不存在:{self.skills_root}")
# 遍历所有子目录里的 SKILL.md
for skill_md in self.skills_root.glob("*/SKILL.md"):
skill = self._load_skill_metadata_and_body(skill_md)
if skill.name in self.skills:
raise ValueError(f"重复的 skill name:{skill.name}")
self.skills[skill.name] = skill
if not self.skills:
raise RuntimeError(f"没有发现任何 SKILL.md:{self.skills_root}")
return self.skills
def list_for_selection(self) -> list[dict[str, str]]:
"""
返回用于选择 skill 的候选列表。
只包含:
1. name
2. description
不包含:
1. SKILL.md 正文
2. advanced.md
3. data 文件
4. references 文件
"""
return [skill.metadata_for_selection() for skill in self.skills.values()]
def get(self, skill_name: str) -> Skill:
"""
根据 skill name 获取 Skill 对象。
参数:
skill_name:
skill 的唯一名称。
返回:
Skill 对象。
"""
if skill_name not in self.skills:
raise KeyError(f"未知 skill:{skill_name}")
return self.skills[skill_name]
def list_resources(self, skill_name: str) -> list[str]:
"""
列出某个 skill 目录下的附加资源。
这也是渐进式披露的一部分。
它只告诉模型:
当前 skill 有哪些资源可用。
但不会直接读取资源内容。
例如可能返回:
[
"advanced.md",
"data/source_policy.json"
]
"""
skill = self.get(skill_name)
resources: list[str] = []
# 遍历 skill 目录下所有文件
for path in skill.directory.rglob("*"):
if path.is_file() and path.name != "SKILL.md":
resources.append(path.relative_to(skill.directory).as_posix())
return sorted(resources)
def read_resource(self, skill_name: str, relative_path: str) -> str:
"""
读取某个 skill 目录下的资源文件。
参数:
skill_name:
当前 skill 名称。
relative_path:
相对于该 skill 目录的资源路径。
例如:
- advanced.md
- data/source_policy.json
安全限制:
只能读取当前 skill 目录内的文件。
禁止通过 ../ 逃逸到其他目录。
"""
skill = self.get(skill_name)
# 计算目标文件绝对路径
target = (skill.directory / relative_path).resolve()
# 当前 skill 目录绝对路径
skill_dir = skill.directory.resolve()
# 防止路径逃逸
if not str(target).startswith(str(skill_dir)):
raise ValueError("非法资源路径:禁止访问 skill 目录之外的文件")
if not target.exists() or not target.is_file():
raise FileNotFoundError(f"资源不存在:{relative_path}")
return target.read_text(encoding="utf-8")
def query_json_resource(
self,
skill_name: str,
relative_path: str,
query: str = "",
limit: int = 5,
) -> str:
"""
查询当前 skill 目录下的 JSON 数据文件。
这个函数用于演示:
大型数据文件不直接塞进 prompt,
而是通过工具按需查询。
参数:
skill_name:
当前 skill 名称。
relative_path:
JSON 文件相对路径。
query:
简单关键词过滤。
为空时返回前 limit 条。
limit:
最多返回多少条。
返回:
JSON 字符串。
"""
# 先读取 JSON 文件内容
raw = self.read_resource(skill_name, relative_path)
# 解析 JSON
data = json.loads(raw)
query_lower = query.lower().strip()
# 把 dict/list 统一转换成列表,方便过滤
if isinstance(data, dict):
items = [{"key": k, "value": v} for k, v in data.items()]
elif isinstance(data, list):
items = data
else:
items = [{"value": data}]
# 如果提供了 query,就做简单字符串匹配
if query_lower:
filtered = []
for item in items:
text = json.dumps(item, ensure_ascii=False).lower()
if query_lower in text:
filtered.append(item)
items = filtered
# 返回前 limit 条
return json.dumps(items[:limit], ensure_ascii=False, indent=2)
def _load_skill_metadata_and_body(self, skill_md: Path) -> Skill:
"""
读取并解析单个 SKILL.md 文件。
SKILL.md 格式:
---
name: xxx
description: >
xxx
allowed_tools: [...]
---
# 正文
返回:
Skill 对象。
"""
text = skill_md.read_text(encoding="utf-8")
if not text.startswith("---"):
raise ValueError(f"{skill_md} 缺少 YAML frontmatter")
try:
# 分割:
# 第一个 --- 前面为空
# 第二部分是 YAML frontmatter
# 第三部分是 Markdown 正文
_, frontmatter_text, body = text.split("---", 2)
except ValueError as exc:
raise ValueError(f"{skill_md} frontmatter 格式错误") from exc
# 解析 YAML
meta: dict[str, Any] = yaml.safe_load(frontmatter_text) or {}
# 检查必需字段
required_fields = ["name", "description"]
for field in required_fields:
if field not in meta:
raise ValueError(f"{skill_md} 缺少必需字段:{field}")
# 构造 Skill 对象
return Skill(
name=str(meta["name"]),
description=str(meta["description"]).strip(),
version=meta.get("version"),
allowed_tools=list(meta.get("allowed_tools", [])),
required_context=list(meta.get("required_context", [])),
license=meta.get("license"),
author=meta.get("author"),
tags=list(meta.get("tags", [])),
directory=skill_md.parent,
body=body.strip(),
)
Agent/tools.py
from __future__ import annotations
import json
import os
from typing import Any, Callable
from tavily import TavilyClient
from agent.skill_loader import Skill, SkillLoader
def tavily_search(query: str, max_results: int = 5, topic: str = "general") -> str:
"""
使用 Tavily 搜索网页。
这个工具用于 web-research skill。
参数:
query:
搜索查询词。
max_results:
最多返回多少条结果。
为了避免上下文太长,这里限制在 1 到 10 之间。
topic:
Tavily 的搜索类型。
常见值:
- general
- news
- finance
返回:
JSON 字符串,包含搜索结果列表。
"""
api_key = os.getenv("TAVILY_API_KEY")
if not api_key:
return json.dumps(
{
"error": "缺少 TAVILY_API_KEY,请在 .env 文件中配置。",
},
ensure_ascii=False,
)
# 限制 max_results 范围,避免一次返回太多内容
max_results = max(1, min(int(max_results), 10))
# 初始化 Tavily 客户端
client = TavilyClient(api_key=api_key)
# 调用 Tavily 搜索
response = client.search(
query=query,
max_results=max_results,
topic=topic,
include_answer=False,
)
# 压缩搜索结果,只保留 Agent 最常用的字段
compact_results = []
for item in response.get("results", []):
compact_results.append(
{
"title": item.get("title"),
"url": item.get("url"),
"content": item.get("content"),
"score": item.get("score"),
}
)
return json.dumps(
{
"query": query,
"results": compact_results,
},
ensure_ascii=False,
indent=2,
)
class ToolExecutor:
"""
工具执行器。
主要职责:
1. 注册所有可用工具。
2. 根据 skill.allowed_tools 暴露工具 schema。
3. 执行工具前检查当前 skill 是否允许调用该工具。
4. 执行工具并返回结果。
"""
def __init__(self, skill_loader: SkillLoader):
"""
初始化 ToolExecutor。
参数:
skill_loader:
SkillLoader 实例。
用于读取 skill 的 advanced.md、data 文件等资源。
"""
self.skill_loader = skill_loader
# 注册所有工具
self.registry: dict[str, dict[str, Any]] = self._build_registry()
def schemas_for(self, allowed_tool_names: list[str]) -> list[dict[str, Any]]:
"""
根据 allowed_tools 返回工具 schema。
参数:
allowed_tool_names:
当前 skill 允许调用的工具名称列表。
返回:
OpenAI Chat Completions tools 参数所需的 schema 列表。
"""
schemas = []
for name in allowed_tool_names:
if name in self.registry:
schemas.append(self.registry[name]["schema"])
return schemas
def execute(self, skill: Skill, tool_name: str, arguments: dict[str, Any]) -> str:
"""
执行某个工具。
参数:
skill:
当前被激活的 Skill。
tool_name:
模型想调用的工具名称。
arguments:
模型生成的工具参数。
关键安全点:
即使模型请求调用某个工具,
Harness 也不会直接执行,
而是先检查 tool_name 是否在当前 skill.allowed_tools 中。
"""
# 白名单检查:防止模型越权调用工具
if tool_name not in skill.allowed_tools:
return json.dumps(
{
"error": f"工具 {tool_name} 不在当前 skill 的 allowed_tools 白名单中。",
"allowed_tools": skill.allowed_tools,
},
ensure_ascii=False,
)
# 工具是否存在
if tool_name not in self.registry:
return json.dumps(
{
"error": f"未知工具:{tool_name}",
},
ensure_ascii=False,
)
try:
# load_skill_resource 是特殊工具:
# 它不直接对应一个普通 Python 函数,
# 而是调用 SkillLoader 读取当前 skill 目录下的资源。
if tool_name == "load_skill_resource":
relative_path = arguments["relative_path"]
content = self.skill_loader.read_resource(
skill_name=skill.name,
relative_path=relative_path,
)
return json.dumps(
{
"skill": skill.name,
"resource": relative_path,
"content": content,
},
ensure_ascii=False,
indent=2,
)
# query_skill_data 也是特殊工具:
# 它用于查询当前 skill 目录下的 JSON 数据文件。
if tool_name == "query_skill_data":
relative_path = arguments["relative_path"]
query = arguments.get("query", "")
limit = int(arguments.get("limit", 5))
content = self.skill_loader.query_json_resource(
skill_name=skill.name,
relative_path=relative_path,
query=query,
limit=limit,
)
return json.dumps(
{
"skill": skill.name,
"resource": relative_path,
"query": query,
"matches": json.loads(content),
},
ensure_ascii=False,
indent=2,
)
# 普通工具直接从 registry 中取函数执行
func: Callable[..., str] = self.registry[tool_name]["func"]
return func(**arguments)
except Exception as exc:
# 工具执行失败时,把错误返回给模型
# 让模型有机会调整下一步
return json.dumps(
{
"error": str(exc),
"tool": tool_name,
},
ensure_ascii=False,
)
def _build_registry(self) -> dict[str, dict[str, Any]]:
"""
注册所有工具。
每个工具包含两部分:
1. func:
Python 函数。
2. schema:
给 OpenAI tool calling 使用的 JSON schema。
注意:
这里只是“注册工具”。
某个 skill 能不能用这些工具,
取决于该 skill 的 allowed_tools。
"""
return {
"tavily_search": {
"func": tavily_search,
"schema": {
"type": "function",
"function": {
"name": "tavily_search",
"description": "使用 Tavily 搜索网页,适合查找最新信息、多来源资料、事实核查。",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "搜索查询词,应该简洁明确。",
},
"max_results": {
"type": "integer",
"description": "返回结果数量,建议 3 到 5。",
"default": 5,
},
"topic": {
"type": "string",
"enum": ["general", "news", "finance"],
"description": "搜索类别。",
"default": "general",
},
},
"required": ["query"],
},
},
},
},
"load_skill_resource": {
"func": None,
"schema": {
"type": "function",
"function": {
"name": "load_skill_resource",
"description": "读取当前 skill 目录下的附加文档,例如 advanced.md。只能读取当前 skill 内部资源。",
"parameters": {
"type": "object",
"properties": {
"relative_path": {
"type": "string",
"description": "相对于当前 skill 目录的资源路径,例如 advanced.md。",
}
},
"required": ["relative_path"],
},
},
},
},
"query_skill_data": {
"func": None,
"schema": {
"type": "function",
"function": {
"name": "query_skill_data",
"description": "查询当前 skill 目录下的 JSON 数据文件,只返回匹配片段,避免把大文件全部放进上下文。",
"parameters": {
"type": "object",
"properties": {
"relative_path": {
"type": "string",
"description": "相对于当前 skill 目录的数据文件路径,例如 data/source_policy.json。",
},
"query": {
"type": "string",
"description": "过滤关键词。为空时返回前几条。",
"default": "",
},
"limit": {
"type": "integer",
"description": "最多返回多少条。",
"default": 5,
},
},
"required": ["relative_path"],
},
},
},
},
}
skills/structured-writing/advanced.md
# Structured Writing Advanced Guide
## 正式语气
适合:
- 工作邮件
- 项目汇报
- 对外说明
- 课程作业
- 正式通知
常用表达:
- “目前”
- “预计”
- “建议”
- “请确认”
- “后续将”
- “为确保……”
避免表达:
- “反正”
- “随便”
- “差不多”
- “搞一下”
- “应该还行”
## 委婉语气
适合:
- 拒绝
- 延期
- 催促
- 说明问题
- 请求配合
可用表达:
- “可能需要更多时间”
- “为了确保质量”
- “是否方便在……前确认”
- “我们建议优先……”
- “如果方便的话,请……”
## 强硬但礼貌
适合:
- 明确边界
- 提醒风险
- 强调截止时间
- 要求对方行动
可用表达:
- “该事项需要在……前完成”
- “如果未能按时确认,可能会影响……”
- “为避免后续风险,请……”
- “请在……前完成确认”
## 营销风格
结构建议:
1. 先讲痛点
2. 再讲解决方案
3. 最后讲收益
示例结构:
> 你是否遇到过……
> 现在,通过……
> 你可以更轻松地……
skills/structured-writing/SKILL.md
---
name: structured-writing
description: >
用于改写、润色、整理、扩写或压缩用户提供的文本,包括邮件、报告段落、说明文、社交媒体文案和学习笔记。
当用户提供一段草稿,并希望它更清晰、更专业、更自然、更有结构、更正式、更委婉或更简洁时,应该使用此技能。
核心价值是把粗糙表达转化成可直接使用的高质量文本,同时保留用户原意。
version: 1.0.0
allowed_tools: [load_skill_resource]
required_context: [user_draft, target_audience, tone]
license: MIT
author: Demo Author <demo@example.com>
tags: [writing, editing, polish, structure]
---
# Structured Writing Skill
## 概述
这个技能用于文本改写和结构化表达。
它不负责事实检索。
如果用户需要最新信息、事实核查或资料来源,应使用 `web-research` skill。
适用场景包括:
- 改写一句话
- 润色邮件
- 整理学习笔记
- 优化报告段落
- 改写社交媒体文案
- 把口语表达改得更正式
- 把冗长表达改得更简洁
## 前置条件
用户最好提供:
- 原始草稿
- 目标读者
- 语气要求
- 长度要求
如果缺少这些信息,可以根据上下文做合理默认:
- 目标读者:普通读者
- 语气:清晰、专业、自然
- 长度:中等
## 工作流程
1. 识别用户想要的改写目标:
- 更专业
- 更口语
- 更简短
- 更正式
- 更委婉
- 更有逻辑
2. 提取原文核心意思。
3. 保留用户原意,不新增未经提供的事实。
4. 优先改善:
- 结构
- 逻辑顺序
- 表达清晰度
- 语气一致性
5. 如果用户要求非常正式、委婉、强硬或营销风格,可以读取:
- `load_skill_resource("advanced.md")`
6. 输出最终可直接使用的版本。
## 最佳实践
- 不要过度改写导致意思变化。
- 用户没有要求多版本时,默认给一个最佳版本。
- 如果用户要求“只要结果”,不要解释。
- 如果原文有明显歧义,可以在最终答案后简短说明。
- 不要为了“高级”而堆砌空话。
- 不要添加原文没有的承诺、数据、事实、时间点。
## 示例
用户:
> 帮我把这句话改得专业一点:这个功能现在还没做完,可能要晚点。
输出:
> 该功能目前仍在开发中,预计需要更多时间完成。我们会在进展明确后及时同步更新。
## 故障排查
- 如果用户要求“不要改变意思”,严格保留语义。
- 如果用户要求“更短”,优先删除重复信息。
- 如果用户要求“更强硬”,提高明确性,但避免攻击性表达。
- 如果用户要求事实补充,但没有提供资料,应提醒需要使用检索类能力。
web-research\data\source_policy.json
[
{
"source_type": "official_docs",
"trust_level": "high",
"use_for": ["API usage", "configuration", "version behavior", "installation"],
"caution": "官方文档适合确认 API、配置、版本行为,但可能不覆盖真实用户体验。"
},
{
"source_type": "github",
"trust_level": "high",
"use_for": ["source code", "issues", "release notes", "examples"],
"caution": "GitHub issue 中的信息可能只是个别案例,需要注意 issue 状态和时间。"
},
{
"source_type": "academic_paper",
"trust_level": "high",
"use_for": ["method", "experiment", "formal definition"],
"caution": "论文结果可能不代表工业落地效果。"
},
{
"source_type": "news_media",
"trust_level": "medium",
"use_for": ["recent events", "public announcements", "market movement"],
"caution": "注意发布时间、信息来源和媒体立场。"
},
{
"source_type": "personal_blog",
"trust_level": "medium",
"use_for": ["practice experience", "tutorial", "engineering tricks"],
"caution": "需要和官方资料交叉验证。"
},
{
"source_type": "forum_or_social_media",
"trust_level": "low",
"use_for": ["user sentiment", "bug symptoms", "anecdotal evidence"],
"caution": "不要把个别用户发言当成事实结论。"
}
]
web-research\advanced.md
# Web Research Advanced Guide
## 查询改写策略
### 技术框架类问题
用户问:
> X 框架现在怎么用?
可以搜索:
- `X official docs`
- `X GitHub examples`
- `X agent framework tutorial`
- `X changelog`
### 最新动态类问题
用户问:
> 最近 X 有什么变化?
可以搜索:
- `X latest update`
- `X changelog`
- `X release notes`
- `X news`
### 事实核查类问题
至少搜索两个不同角度:
- 官方来源
- 独立媒体或第三方资料
## 来源优先级
一般优先级:
1. 官方文档
2. 官方 GitHub / changelog
3. 论文 / 标准文档
4. 主流媒体 / 专业机构
5. 个人博客
6. 论坛、社交媒体、二手转载
## 什么时候需要更多搜索
以下情况应该追加搜索:
- 搜索结果互相矛盾
- 用户要求“最新”
- 用户要求“准确引用来源”
- 涉及价格、政策、版本、API、法规
- 搜索结果明显不够权威
web-research\SKILL.md
---
name: web-research
description: >
用于回答需要联网检索、最新资料、多来源事实核查、资料综述或来源引用的问题。
当用户的问题涉及最近变化、实时信息、外部网页资料、新闻、产品、框架、论文、技术动态或需要查证的信息时,应该使用此技能。
核心价值是把开放网络信息检索、来源筛选、结果整合和不确定性说明组织成可靠的研究流程。
version: 1.0.0
allowed_tools: [tavily_search, load_skill_resource, query_skill_data]
required_context: [user_question]
license: MIT
author: Demo Author <demo@example.com>
tags: [web, research, tavily, fact-checking]
---
# Web Research Skill
## 概述
这个技能用于处理需要外部网页信息的问题。
它的重点不是“搜到一个结果就回答”,而是让 Agent:
1. 先理解用户真正想查什么。
2. 再设计合适的搜索 query。
3. 然后比较多个来源。
4. 最后综合回答,并说明不确定性。
适用场景包括:
- 最新技术动态
- 框架、工具、库的当前用法
- 产品、政策、价格、版本变化
- 需要来源支撑的事实核查
- 需要多来源综述的问题
## 前置条件
需要环境变量:
- `TAVILY_API_KEY`
如果没有这个 key,`tavily_search` 工具会返回错误信息。
## 工作流程
1. 判断用户问题是否需要最新信息或外部来源。
2. 将用户问题改写成简洁搜索 query。
3. 调用 `tavily_search` 获取 3 到 5 个结果。
4. 阅读每个结果的:
- title
- url
- content
- score
5. 优先使用更权威、更直接、更近期的来源。
6. 如果问题涉及来源可靠性、边缘情况或搜索策略,可以调用:
- `load_skill_resource("advanced.md")`
7. 如果需要判断不同来源类型的可信度,可以调用:
- `query_skill_data("data/source_policy.json", query="...")`
8. 最终回答必须:
- 先给结论
- 再给依据
- 明确哪些信息来自搜索结果
- 对不确定处说明“不确定”或“需要进一步验证”
## 最佳实践
- 查询词应短,不要把用户的长问题原样丢给搜索引擎。
- 技术问题优先找官方文档、GitHub、论文、主流技术博客。
- 新闻问题优先找多个独立媒体或官方发布。
- 不要把搜索结果里的营销话术直接当事实。
- 如果搜索结果不足,不要编造。
- 如果结果互相矛盾,应说明冲突,而不是强行给单一结论。
## 示例
用户:
> 请查一下 LangGraph 现在主要适合用来做什么?
推荐流程:
1. 搜索 `LangGraph current use cases agents`
2. 对比官方文档、GitHub、技术博客
3. 总结 LangGraph 的主要使用场景
4. 说明依据来自搜索结果
## 故障排查
- 如果 Tavily 返回结果很少,换更短 query。
- 如果结果质量差,加入 `official docs`、`GitHub`、`paper` 等限定词。
- 如果用户问的是观点类问题,不要只搜索一个来源。
- 如果用户要求“最新”,搜索词里可以加入年份或 `latest`。
在这个demo中,如何知道用哪个skill:harness.py 的 _select_skill(),模型会返回一个json格式的 skill_name ,然后 Harness 根据 skill_name 取出完整 Skill(即选择skill的过程是让一个LLM根据用户query、skill-name和description做分类识别任务);找到skill后如何根据 skill 找 tool:每个 SKILL.md 的 frontmatter 里写了 allowed_tools,Harness 读取 allowed_tools,ToolExecutor 只暴露这些工具的 schema(从全局工具注册表 registry 中拿到这些工具的 schema)。如何根据 skill 找 advanced.md:目录扫描(Harness会调用一个函数扫描当前 skill 目录下除了 SKILL.md 以外的文件,然后把这些资源路径放进 system prompt--只是告诉agent可用)
以上最小 demo 为了简单,做的是单 skill 路由,如果一个任务需要多个 skill,就要升级成多 skill 编排。
- 顺序式 Pipeline(适合任务天然有先后顺序/步骤)
- 并行式 Fan-out(适合多个 skill 可以各自独立完成一部分,然后再合并)
- 动态式 Planner(适合复杂任务,开始时不确定要用哪些 skill,个人感觉类似于react,先执行一个skill后看结果决定下一步,直到完成)
代码中,需要让 selector 返回列表,并将每一步只暴露当前 skill 的 allowed_tools。
4.5.3.tool注册方式与skill注册方式对比
方式1:llm.bind_tools(tools) —— 最底层,只绑定不循环。
这是最直接的方式。你只把工具"挂"到大模型上,让它具备调用工具的"知识"。虽然大模型说了"我要调用 get_weather",**实际执行 get_weather("北京") 这行代码,是你手动写的!**
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""获取指定城市的天气"""
return f"{city} 的天气是晴天,25°C"
llm = ChatOpenAI(model="gpt-4o-mini")
tools = [get_weather]
# 仅把工具绑定到模型,让模型知道如何调用,但不自动执行
llm_with_tools = llm.bind_tools(tools)
# 调用后,如果模型决定调用工具,response 里会有 tool_calls 字段
response = llm_with_tools.invoke("北京今天天气怎么样?")
print(response.tool_calls) # 输出:[{'name': 'get_weather', 'args': {'city': '北京'}, ...}]
# 但你需要自己写代码去执行这个 tool_calls,并把结果再喂给模型
方式2:create_agent —— 官方推荐的新一代 Agent
这是 LangChain v1 和 LangGraph v1 官方推荐的创建 Agent 的方式。它同样基于 LangGraph,但提供了更简洁的接口和更好的中间件(Middleware)支持(比如人机回环、结构化输出等)
# 注意:这是最新的推荐用法
from langchain import create_agent
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""获取指定城市的天气"""
return f"{city} 的天气是晴天,25°C"
llm = ChatOpenAI(model="gpt-4o-mini")
tools = [get_weather]
# create_agent 是 LangChain v1 的新函数,内部基于 LangGraph
agent = create_agent(model=llm, tools=tools)
# 使用方式和 LangGraph 一致
result = agent.invoke({"messages": [("user", "北京今天天气怎么样?")]})
print(result["messages"][-1].content) # 输出:北京今天天气是晴天,25°C
skill的create_agent类似
from langchain import create_agent
from langchain_openai import ChatOpenAI
from langchain_core.tools import StructuredTool
import os
import yaml # 需要安装:pip install pyyaml
# ============ 1. 加载 Skill ============
def load_skill(skill_folder_path: str) -> StructuredTool:
"""
从文件夹加载 Skill,返回一个可调用的 Tool 对象
"""
skill_md_path = os.path.join(skill_folder_path, "SKILL.md")
# 解析 SKILL.md 的 YAML 头部
with open(skill_md_path, "r", encoding="utf-8") as f:
content = f.read()
# 提取 YAML 头(--- 之间的内容)
lines = content.split("\n")
yaml_content = []
in_yaml = False
for line in lines:
if line.strip() == "---":
in_yaml = not in_yaml
continue
if in_yaml:
yaml_content.append(line)
yaml_data = yaml.safe_load("\n".join(yaml_content))
skill_name = yaml_data.get("name", "unknown_skill")
skill_description = yaml_data.get("description", "No description provided")
# 导入执行器
from skill_executor import execute_code_review
# 创建 Tool 对象(核心:把 Skill 包装成 Tool)
def skill_wrapper(code: str) -> str:
"""执行 Skill 的逻辑"""
return execute_code_review(code, skill_folder_path)
return StructuredTool.from_function(
func=skill_wrapper,
name=skill_name,
description=skill_description
)
# ============ 2. 注册 Skill 到 Agent ============
# 加载 Skill
skill_tool = load_skill("./my_skills/code_review_skill/")
# 创建 LLM
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
# 用 create_agent 创建 Agent(这是官方推荐的方式)
agent = create_agent(
model=llm,
tools=[skill_tool], # 把 Skill 当作普通 Tool 注册进去
system_prompt="你是一个专业的代码审查助手,擅长发现代码中的问题并给出改进建议。"
)
# ============ 3. 使用 Skill ============
user_input = """
帮我 review 这段 Python 代码:
def sum(a,b):
return a+b
"""
# 调用 Agent
result = agent.invoke({
"messages": [
("user", user_input)
]
})
# 打印结果
print(result["messages"][-1].content)
4.6.Harness
Harness 可以理解为“模型外面的工程外壳”。LangChain 的说法很直接:Agent = Model + Harness,如果不是模型本身,那基本都属于 Harness;它包括 system prompt、tools、skills、MCP、文件系统、沙箱、浏览器、编排逻辑、middleware、日志等。顾名思义,缰绳,简单一些理解就是负责规范大模型行为的判断、是否、限制、记录等。
4.7.Loop Engineering
Loop Engineering 是一个比较新的说法。Addy Osmani 对它的解释是:你不再亲自一轮一轮提示 Agent,而是设计一个系统,由这个系统持续提示、分配任务、检查结果、记录状态、决定下一步。Oracle 把 agent loop 定义为 Harness 在一次 Agent run 中反复执行的循环:组装上下文、调用模型、行动、把轨迹追加回上下文,并一直重复到停止条件满足。
Loop Engineering 和 ReAct 的区别
- ReAct:一种模型推理范式,强调 Thought / Action / Observation
- Loop Engineering:一种工程设计方法,强调谁来驱动循环、如何验证、何时停止、如何恢复、如何记录经验
也就是说,ReAct 是 loop 里面的一种“思考-行动模式”;Loop Engineering 是你在 Harness 层设计整个闭环系统。
5.1.如何解决大模型输出非标准JSON的问题
生成阶段尽量约束,应用阶段最后兜底。总览:
用户输入
↓
LLM
│
├─ 普通生成
│ ↓
│ 一段字符串
│ ↓
│ json-repair ← 修 JSON 语法
│ ↓
│ Pydantic ← 验证字段、类型、结构
│
├─ Tool Calling
│ ↓
│ tool.arguments ← API 层按参数 Schema 生成
│ ↓
│ 你的 Python 函数
│
├─ Structured Outputs
│ ↓
│ JSON Schema ← API 层要求最终回答符合 Schema
│ ↓
│ Pydantic 对象
│
└─ 本地模型 / vLLM / Outlines
│
├─ 受限解码,每生成一个 token 就限制合法 token
│
└─ SFT
流式场景:
token/chunk → token/chunk → token/chunk
↓
partial-json-parser
↓
当前已完整字段
5.1.1.为什么“Prompt 恐吓 + 正则 + 重试”不是好方案?
最原始的做法一般是:
prompt = """
必须返回 JSON!!!
绝对不允许输出其他内容!!!
格式错误将受到惩罚!!!
"""
然后模型却返回:
好的,结果如下:
```json
{
"name": "Tom",
"age": 20
}
```
于是开始正则:
import re
match = re.search(r"\{.*\}", text, re.S)
再 json.loads()。
如果失败:
再问一次模型:
“你的 JSON 格式错了,请重新回答!”
这种方式的问题有三个:
- Prompt 只是“软约束”,模型知道你希望它输出 JSON,但它仍然是在生成自然语言如“Sure! Here is the JSON:”或可以输出 Markdown 如“```json”,Prompt 再凶,也不会改变“它仍然可以生成任意 token”这件事。
- 正则不适合解析 JSON,JSON 本质上是一个有语法结构的语言(可能有任意层嵌套、转义等),不应该靠简单规则去解析。
- 无脑重试成本很高,时间成本和 token 成本都在提高。而且如果失败原因是模型本身不稳定,第二次也可能产生另一种错误。
所以:
重试可以作为最后一道保险,但不应该成为结构化输出的主要机制。
5.1.2.应用层好的约束:Pydantic + json-repair
json-repair 是什么?
Python 标准库:
json.loads(...)
要求输入必须是严格合法 JSON。
比如:
import json
json.loads("""
{
'name': 'Tom',
'age': 20,
}
""")
会报错。
因为 JSON 要求:
{
"name": "Tom",
"age": 20
}
不能:
- 单引号
- 最后一项多逗号
- 少括号
- 少引号
- 混入部分额外文本等
json-repair 就是一个“损坏 JSON 修复器”。
比如:
from json_repair import loads
text = """
{
'name': 'Tom',
'age': 20,
"""
obj = loads(text)
print(obj)
可能得到:
{
"name": "Tom",
"age": 20
}
它会使用一些启发式规则修复缺少的引号、逗号、括号等。但注意:json-repair 主要解决 JSON“语法坏了”,并不天然知道你的业务结构对不对。
例如:
{
"username": "Tom",
"age": "二十岁"
}
它完全可能是合法 JSON。
但你需要的是:
{
"name": "Tom",
"age": 20
}
这时候就需要 Pydantic。
Pydantic 是什么?
假设你的程序要求模型返回:
{
"name": "小王",
"age": 23,
"skills": ["Python", "SQL"]
}
你可以直接用 Python 定义:
from pydantic import BaseModel
class Person(BaseModel):
name: str
age: int
skills: list[str]
这句话实际上就是在定义:
Person
├── name: 字符串
├── age: 整数
└── skills: 字符串数组
然后:
data = {
"name": "小王",
"age": 23,
"skills": ["Python", "SQL"]
}
person = Person.model_validate(data)
print(person)
得到:
name='小王' age=23 skills=['Python', 'SQL']
之后你不再处理乱七八糟的 dict:
person.name
person.age
person.skills
都已经有明确类型了。
Pydantic 怎么自动生成 JSON Schema?
这正是 Pydantic 很重要的功能。
schema = Person.model_json_schema()
print(schema)
大概得到:
{
"properties": {
"name": {
"title": "Name",
"type": "string"
},
"age": {
"title": "Age",
"type": "integer"
},
"skills": {
"items": {
"type": "string"
},
"title": "Skills",
"type": "array"
}
},
"required": [
"name",
"age",
"skills"
],
"title": "Person",
"type": "object"
}
Pydantic 官方提供的就是 model_json_schema();反过来又可以用 model_validate() / model_validate_json() 验证数据。
这非常关键,因为:
Python 类型定义
↓
Pydantic
↓
JSON Schema
↓
OpenAI / vLLM / Tool Calling / Structured Outputs
于是你不需要:
Python 类型写一遍
JSON Schema 再手写一遍
校验代码再写一遍
Pydantic + json-repair 如何组合?
最容易理解、也很实用的是:
LLM
↓
字符串
↓
json-repair
↓
Python dict
↓
Pydantic
↓
合法、带类型的数据对象
也就是:
raw_text
↓
json_repair.loads()
↓
dict
↓
Person.model_validate()
↓
Person
这里有一个容易误解的地方:“Pydantic + json-repair”不代表一定要把 Pydantic Schema 传给 json-repair。最经典的组合就是:
repair JSON 语法
↓
Pydantic 检查业务结构
以下是一个简单demo:
import os
from dotenv import load_dotenv
from openai import OpenAI
from pydantic import BaseModel
from json_repair import loads as repair_loads
load_dotenv()
client = OpenAI(
base_url=os.getenv("url"),
api_key=os.getenv("apikey"),
)
MODEL = os.getenv("modelid")
class Person(BaseModel):
name: str
age: int
skills: list[str]
response = client.chat.completions.create(
model=MODEL,
messages=[
{
"role": "user",
"content": """
从下面文本中提取信息,并返回 JSON:
小王今年23岁,会Python和SQL。
需要字段:
name
age
skills
"""
}
]
)
# 1. LLM 原始输出
raw = response.choices[0].message.content
print("LLM原始输出:")
print(raw)
# 2. 修复 / 解析 JSON
obj = repair_loads(raw)
print("\n修复后的dict:")
print(obj)
# 3. Pydantic验证
person = Person.model_validate(obj)
print("\nPydantic对象:")
print(person)
print("\n程序读取:")
print(person.name)
print(person.age)
print(person.skills)
5.1.3.闭源模型 API 层约束
这一层有两个非常重要的方法:
API级结构约束
│
┌──────────┴──────────┐
↓ ↓
Tool Calling Structured Outputs
↓ ↓
函数参数结构 最终回答结构
它们不是完全相同的东西。
Tool Calling / Function Calling 方法
假设你真的有一个 Python 函数:
def create_user(name: str, age: int):
...
你希望模型面对:
帮我创建用户小王,今年23岁
不要回答:
好的,我将创建小王。
而是告诉你的程序:
{
"name": "小王",
"age": 23
}
你的程序再:
create_user(
name="小王",
age=23
)
这就是 Function Calling。
@tool 是 LangChain 自己的封装。OpenAI API 本身根本不要求 @tool。原生 API 要的是:
tools=[
{
"type": "function",
"function": {
"name": "...",
"description": "...",
"parameters": {
# JSON Schema
}
}
}
]
也就是说,真正重要的是:
函数名称
函数说明
函数参数 JSON Schema
模型不会真的执行:
create_user(...)
它返回的是类似:
{
"name": "create_user",
"arguments": "{\"name\":\"小王\",\"age\":23}"
}
于是:
tool_call = response.choices[0].message.tool_calls[0]
print(tool_call.function.name)
print(tool_call.function.arguments)
得到:
create_user
{"name":"小王","age":23}
然后是你的代码决定:
args = json.loads(tool_call.function.arguments)
create_user(**args)
所以完整过程:
用户
│
│ “创建小王,23岁”
↓
LLM
│
│ tool call
↓
{
name: create_user,
arguments: {
name: 小王,
age: 23
}
}
│
↓
你的程序
│
↓
create_user(name="小王", age=23)
Function Calling 的作用正是把模型连接到你自己的程序、API、数据库等系统。OpenAI 当前支持通过 strict: true 要求 function arguments 遵守所提供的 JSON Schema。
这一方法也就是把需要固定在 json 中的内容作为一个 tool 的参数,让 llm 在 toolcall 机制下按要求输出 tool 的参数时一并做到“标准 json”。(AI 只负责生成调用的参数,不需要真的实现这个 name的 tool)
Pydantic 可以自动把参数结构变成 Tool
还是:
class Person(BaseModel):
name: str
age: int
skills: list[str]
OpenAI Python SDK 提供:
openai.pydantic_function_tool(...)
它自动做:
Pydantic
↓
JSON Schema
↓
OpenAI function tool schema
官方 SDK 就支持这种 Pydantic function tool 自动转换。以下还是一个简单demo:
import os
import openai
from dotenv import load_dotenv
from openai import OpenAI
from pydantic import BaseModel
load_dotenv()
client = OpenAI(
base_url=os.getenv("url"),
api_key=os.getenv("apikey"),
)
MODEL = os.getenv("modelid")
class Person(BaseModel):
name: str
age: int
skills: list[str]
# 创建辅助工具,自动将 Pydantic 模型转换为 OpenAI 要求的 Function Calling 格式
person_tool = openai.pydantic_function_tool(
Person, # Pydantic 模型类,定义了数据结构
name="submit_person", # 工具函数的名称,AI 调用时会用这个名字
description="提交抽取到的人员信息"# 告诉 AI 这个工具是做什么的,帮助 AI 决定何时调用
)
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "user", "content": "小王今年23岁,会Python和SQL,请提交他的人员信息。"}
],
tools=[person_tool],
tool_choice={ # tool_choice是个参数,强制调用某个函数
"type": "function",
"function": {"name": "submit_person"}
}
# 自动(默认)- AI 自己决定是否调用:tool_choice="auto"
# 不调用任何函数:tool_choice="none"
)
# 处理响应
tool_call = response.choices[0].message.tool_calls[0]
print("调用的工具:", tool_call.function.name)
print("\n原始arguments:", tool_call.function.arguments)
# Pydantic验证
person = Person.model_validate_json(tool_call.function.arguments)
print("\nPydantic对象:", person)
print(f"\n下游读取:\n姓名:{person.name}\n年龄:{person.age}\n技能:{', '.join(person.skills)}")
返回类似:
调用的工具: submit_person
原始arguments: {"name":"小王","age":23,"skills":["Python","SQL"]}
Pydantic对象: name='小王' age=23 skills=['Python', 'SQL']
下游读取:
姓名:小王
年龄:23
技能:Python, SQL
openai.pydantic_function_tool函数的输出就返回一个字典,等价于:
{
"type": "function",
"function": {
"name": "submit_person",
"description": "提交抽取到的人员信息",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"skills": {"type": "array", "items": {"type": "string"}}
},
"required": ["name", "age", "skills"]
}
}
}
不使用pydantic辅助函数,原生写法就是手动定义 JSON Schema:
person_tool = {
"type": "function",
"function": {
"name": "submit_person",
"description": "提交抽取到的人员信息",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "人员姓名"
},
"age": {
"type": "integer",
"description": "人员年龄"
},
"skills": {
"type": "array",
"items": {"type": "string"},
"description": "技能列表"
}
},
"required": ["name", "age", "skills"]
}
}
}
也不需要真的实现“submit_person”,这是 Function Calling 的核心机制:
- AI 生成参数:AI 只负责生成调用的参数(JSON 格式)
- 你负责执行:你拿到参数后,自己决定如何处理
如果真的需要调用“submit_person”,反正参数也已经按标准json字段返回了,可以
def submit_person(name, age, skills):
print("写入数据库:")
print(name, age, skills)
调用:
submit_person(
name=person.name,
age=person.age,
skills=person.skills
)
或者:
submit_person(**person.model_dump())
后者非常常见。
这已经不是单纯:
Prompt:
“拜托你严格按照JSON输出”
而是 API 提供的结构化约束。
Structured Outputs 方法
这是一个特别容易和 Tool Calling 混淆的东西。
假设我的任务只是:从一句话里提取人员信息。但是我其实根本没有:
submit_person()
这个工具。
我只是希望模型最终回答本身就是:
{
"name": "小王",
"age": 23,
"skills": ["Python", "SQL"]
}
这时候使用 Tool Calling 其实有点绕(需要以toolcall的参数的形式)。更自然的是:Structured Outputs。Structured Outputs = 给回答定义类型。
普通模型:
用户
↓
LLM
↓
任意文字
Structured Outputs:
用户
↓
LLM
│
│ Schema = Person
↓
{
"name": str,
"age": int,
"skills": list[str]
}
相当于说:你的回答不是“字符串随便写”,而必须是 Person 类型。
JSON mode 和 Structured Outputs 不一样:
JSON Mode
↓
“是JSON就行”
Structured Outputs
↓
“不仅得是JSON,
而且必须符合我规定的JSON Schema”
OpenAI 当前的 response_format.type="json_schema" 就是 Structured Outputs;strict=true 表示严格 Schema adherence,不过只支持 JSON Schema 的一个子集。以下仍然是一个简单demo:
import os
from dotenv import load_dotenv
from openai import OpenAI
from pydantic import BaseModel, ConfigDict
load_dotenv()
client = OpenAI(
base_url=os.getenv("url"),
api_key=os.getenv("apikey"),
)
MODEL = os.getenv("modelid")
class Person(BaseModel):
# 不允许额外字段
model_config = ConfigDict(extra="forbid")
name: str
age: int
skills: list[str]
schema = Person.model_json_schema()
print("Pydantic生成的JSON Schema:")
print(schema)
response = client.chat.completions.create(
model=MODEL,
messages=[
{
"role": "user",
"content": "小王今年23岁,会Python和SQL,请提取他的人员信息。"
}
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "person",
"schema": schema,
"strict": True
}
}
)
raw = response.choices[0].message.content
print("\n模型返回:")
print(raw)
person = Person.model_validate_json(raw)
print("\nPydantic对象:")
print(person)
print(person.name)
print(person.age)
print(person.skills)
OpenAI Python SDK 甚至可以把上面进一步简化,把create方法改成parse,直接:
response = client.chat.completions.parse(
model=MODEL,
messages=[
{
"role": "user",
"content": "小王今年23岁,会Python和SQL,请提取他的人员信息。"
}
],
response_format=Person
)
person = response.choices[0].message.parsed
print(person)
print(person.name)
Tool Calling vs Structured Outputs
| 场景 | Tool Calling | Structured Outputs |
|---|---|---|
| 提取结构化数据 | 可以 | 推荐 |
| 分类结果 | 可以 | 推荐 |
| Planner 输出结构 | 可以 | 推荐 |
| 调数据库 | 推荐 | 不合适 |
| 调天气 API | 推荐 | 不合适 |
| 执行下单 | 推荐 | 不合适 |
| MCP/tool 参数 | 推荐 | 不合适 |
| 返回固定 JSON 给后端 | 可以 | 推荐 |
| 结果位置 | tool_calls.arguments | assistant 内容/parsed |
| 是否表达“我要调用函数” | 是 | 否 |
一句话:Structured Outputs 约束“回答长什么样”;Tool Calling 约束“调用某个能力时参数长什么样”。
5.1.4.开源模型的受限解码
该方法只能用于本地部署的开源模型,对于通过API调用闭源模型,通常无法让你自己直接实现受限解码,但模型提供商可能会在服务端以结构化输出API的形式提供类似的能力。因为受限解码(Constrained Decoding)的核心,是在模型生成每个Token时,强制干预其选择,只允许输出符合预设格式的Token。要实现这种干预,你必须在模型推理的每一步都实时访问和修改其输出的概率分布(Logits)。
对于普通模型,假设模型现在已经生成:
{"age":
模型下一步会给整个词表中的 token 打分:
token probability
" 30%
2 25%
3 20%
null 10%
hello 5%
{ 3%
...
正常采样就是:
所有 token
↓
根据 logits / probability
↓
选一个
所以它完全可能生成:
{
"age": "twenty three"
}
甚至格式坏掉。那受限解码做什么?
如果 Schema 规定:
"age": {
"type": "integer"
}
那么解码器发现当前状态:
{"age":
之后合法的只能是:
空格
-
0~9
...
那么它直接把:
"
hello
[
{
true
这些 token 禁止掉。
概念上:
模型 logits
" 9.1
2 8.7
hello 8.0
3 7.6
{ 6.9
↓ JSON grammar mask
" -∞
2 8.7
hello -∞
3 7.6
{ -∞
↓
只能从 2 / 3 / ... 中采样
当前 vLLM 的 structured outputs 可以使用 JSON Schema、regex、choice、grammar 等约束,并且完全可以使用 OpenAI-compatible Chat API。
5.1.5.想要流式输出怎么办?partial-json
对于普通模型,在最终 } 出来以前:
json.loads(buffer)
基本都会失败:
JSONDecodeError
因为:
{"name":"小王","age":
不是完整 JSON。
partial-json-parser 做什么?它允许:
loads('{"key": "v')
这种没有闭合的 JSON仍然解析出目前能确定的部分。所以它非常适合:
LLM streaming
↓
buffer不断增长
↓
partial JSON parser
↓
React / Vue 页面不断刷新
一个简单实验:
from partial_json_parser import loads, Allow
chunks = [
'{"name":',
'"小王",',
'"age":',
'23,',
'"skills":',
'["Python",',
'"SQL"]',
'}'
]
buffer = ""
for chunk in chunks:
buffer += chunk
print("\n当前buffer:")
print(buffer)
try:
obj = loads(
buffer,
Allow.OBJ | Allow.ARR
)
print("目前可解析:")
print(obj)
except Exception:
pass
你可能会逐渐看到类似:
目前可解析:
{}
然后:
{
'name': '小王'
}
再:
{
'name': '小王',
'age': 23
}
最后:
{
'name': '小王',
'age': 23,
'skills': ['Python', 'SQL']
}
这就是所谓:结构化打字机效果。以下是OpenAI Chat 流式 Demo:
import os
from dotenv import load_dotenv
from openai import OpenAI
from partial_json_parser import loads, Allow
load_dotenv()
client = OpenAI(
base_url=os.getenv("url"),
api_key=os.getenv("apikey"),
)
MODEL = os.getenv("modelid")
stream = client.chat.completions.create(
model=MODEL,
messages=[
{
"role": "user",
"content": """
请用JSON返回下面的信息:
小王今年23岁,会Python和SQL。
格式:
{
"name": "...",
"age": 0,
"skills": ["..."]
}
"""
}
],
# 至少保证最终是JSON
response_format={
"type": "json_object"
},
stream=True
)
buffer = ""
for chunk in stream:
delta = chunk.choices[0].delta.content
if not delta:
continue
buffer += delta
print(delta, end="", flush=True)
try:
partial_data = loads(
buffer,
Allow.OBJ | Allow.ARR
)
print("\n\n当前已经可解析的数据:")
print(partial_data)
print("\n继续接收:")
except Exception:
pass
于是前端就不一定需要等:
5秒后完整JSON突然出现
而可以:
name 小王
↓
age 23
↓
skills Python
↓
skills Python, SQL
逐渐渲染。
不过一定要注意:partial-json 是 UI / streaming 解析工具,不是最终数据校验器。
流结束以后,仍然应该:
final_person = Person.model_validate_json(buffer)
最终再严格校验一次。model_validate_json 是 Pydantic 库(v2版本)中的方法,作用是从 JSON 字符串直接创建 Pydantic 模型实例,包含完整的类型验证和转换。
5.1.6.问题总结
| 方法 | 发生时间 | 解决什么 | 强度 |
|---|---|---|---|
| Prompt 要求 JSON | 生成前 | 提醒模型 | ★ |
| 正则 | 生成后 | 截取文本 | ★ |
| json-repair | 生成后 | 修复 JSON 语法 | ★★ |
| Pydantic | 生成后 | 验证字段/类型/结构 | ★★★ |
| JSON Mode | 生成阶段/API | 保证是 JSON | ★★★ |
| Tool Calling + strict | API/生成阶段 | 保证函数参数结构 | ★★★★ |
| Structured Outputs + strict | API/生成阶段 | 保证回答 Schema | ★★★★ |
| vLLM/Outlines 受限解码 | token 生成阶段 | 禁止非法 token | ★★★★★ |
| partial-json | 流式过程中 | 解析尚未结束的 JSON | 不是“正确性层” |
如果你的模型 API 支持 Structured Outputs,对于“意图识别、信息抽取、Planner 输出、Agent 内部决策结果”这种任务,GPT建议优先写成:
class Output(BaseModel):
...
response = client.chat.completions.parse(
model=MODEL,
messages=messages,
response_format=Output,
)
result = response.choices[0].message.parsed
如果你的模型要:
查数据库
调用搜索
调用 MCP
发送邮件
查询设备
执行某个 action
那么:
class ToolArgs(BaseModel):
...
tools = [
openai.pydantic_function_tool(
ToolArgs,
name="xxx"
)
]
走 Tool Calling。
如果你的模型供应商只是一个普通 OpenAI-compatible API,不支持 Structured Outputs:
Prompt要求JSON
↓
json-repair
↓
Pydantic
仍然非常实用。
如果你自己部署:
Qwen / Llama
↓
vLLM
那么优先考虑:
Pydantic
↓
JSON Schema
↓
vLLM structured_outputs
↓
受限解码
更多推荐
所有评论(0)