学习 Hermes Agent 这类 AI 大模型侧的 Agent 工程时,最容易踩的坑不是模型不会回答,而是把 Session 会话、Skill 技能、工具调用和上下文加载当成四件独立的事。实际跑一个能用的 Agent,这四部分必须围绕同一条请求链路协作:用户输入进入后,系统先加载会话和历史上下文,再把可用技能与工具描述放进提示词,模型决定调用哪个工具,工具执行结果回填给模型,最后产出回复并持久化会话。本文会从这四个核心概念开始,用一个最小可运行的 Python 工程把它们串起来,并给出环境配置、目录设计、代码实现、运行验证、常见问题和生产部署建议。
这套内容适合三类读者:第一次接触 Agent 开发,想理解 Session、Skill、Tool 和上下文加载之间关系的开发者;已经能调用大模型 API,但觉得多轮对话和工具调用难以组织的人;准备在本地或测试环境部署 Hermes Agent,想先建立工程化思维的技术人员。
1. 先搞懂 Agent 的四个关键概念,再动手写代码
1.1 Hermes Agent 不是模型,而是一套 Agent 运行工程
很多资料把 Agent 简单理解成“接一个大模型 API 就能自动完成任务的程序”,这个理解会直接导致后期代码乱。模型只负责推理和文字生成,真正让 Agent 具备状态管理、技能扩展、工具调用能力的是外面这一层运行框架。
Hermes Agent 的学习重点,是 Session 会话管理、Skill 技能注册、Tool 工具调用、Context 上下文加载这四层。它们各自解决一个具体问题:
- Session 记录“之前发生过什么”。
- Skill 告诉模型“当前具备哪些专项能力”。
- Tool 把模型的决定变成真实可执行的函数。
- Context 决定“这一次请求到底能把哪些信息放进输入”。
如果一上来就写 model.chat.completions.create,代码很短,但后续加一个技能、接一个工具、做一次会话恢复,都会变得非常难受。先理解这四层,后面的部署和排错才有依据。
1.2 Session 会话不是聊天记录,而是完整运行状态
很多开发者在 Web 项目里用过 Session,容易下意识把 Agent Session 等同于浏览器会话。两者概念接近,但 Agent 里的 Session 需要保存的内容更复杂,至少包括:
- 会话 ID。
- 模型名称和参数快照。
- 多轮 user、assistant 消息。
- 中间出现的工具调用和结果。
- 技能执行状态和上下文引用。
- 更新时间、来源渠道等元数据。
在纯 API 调用中,如果你把每条消息每次请求都重新传给模型,确实也能实现多轮对话。但这里的问题是状态不可控:用户清空历史时如何同步?工具执行产生中间结果时如何回填?切换模型后旧历史还能不能用?Session 的职责就是把这些问题统一收纳到结构化存储中。
1.3 Skill 技能负责把“会做什么”注入给模型
Skill 的粒度比 Tool 大。一个 Skill 往往是一套能力目录,里面写明它适合处理什么任务、需要调用哪些 Tool、应该按什么顺序执行。
例如笔记助手是一个 Skill,它内部可以包含三个 Tool:list_notes、read_note、save_note。模型只有在用户请求涉及笔记时才激活这个 Skill,激活后 Prompt 中会出现这个技能的说明。不要把所有技能一次性全塞进系统提示词,模型在超长上下文中反而会丢失关键指令。Skill 注册表的价值就是按需装配。
1.4 Tool 工具负责把模型决定变成可执行动作
模型本身不能直接操作文件系统、数据库或外部 API。Tool 就是模型与真实世界的桥梁。
模型不会自己执行代码,它只会输出一次“结构化决定”,例如:
{ "name": "save_note", "arguments": "{\"title\": \"Session学习笔记\", \"content\": \"Session状态要先持久化再继续下一轮对话\"}" }Agent 侧拿到这个结果后,去本地函数注册表里找到 save_note,执行它,再把执行结果转成消息返回给模型。缺少这一步,模型能力再强也只能在文字世界内打转。
1.5 Context 上下文加载决定模型这次能看到什么
模型是无状态推理引擎,每次请求是否能看到历史、工具结果、外部知识,完全取决于 Agent 如何构造 messages。
合理做法是分层组装:
- System 层:固定系统角色和全局规则。
- Skill 层:当前任务激活的技能说明。
- History 层:最近且未超过预算的历史消息。
- ToolResult 层:本次链路刚产生的工具执行结果。
- User 层:用户本次输入。
- Retrieval 层:如果需要外挂知识库,则把命中片段插入到历史消息前后。
Context 拼接顺序对模型理解影响很大。工具结果必须紧跟调用它的助理消息,否则模型无法把结果对应到具体动作;历史消息过长时优先丢弃中间步骤,保留最近意图和最终结论。
2. 环境准备与目录设计:先搭一个可复现的最小工程
2.1 版本和环境要求
不建议一开始就在不确定的前提下使用最新版桌面安装包。任何 Agent 工程都应该先记录一套可复现的环境组合,出现问题时才能定位是代码问题还是依赖问题。
| 项目 | 建议要求 | 说明 |
|---|---|---|
| 操作系统 | Windows 10/11、Ubuntu 20.04+、macOS | 后续命令以 Linux/macOS 为主,Windows 注意路径写法 |
| Python | 3.10 或更高 | 需要 dataclass、类型注解等现代语法 |
| 包管理 | pip、venv | 避免直接装到系统 Python |
| 模型接口 | OpenAI 兼容接口 | 支持配置 base_url,便于切换本地模型服务 |
| 磁盘空间 | 预留 500MB 以上 | 仅代码学习;本地模型另算 |
| 网络 | 能访问模型 API 即可 | 生产环境还需考虑访问控制和审计 |
如果实际下载的 Hermes Agent 安装包自带运行环境,优先按官方 README 的版本要求执行。下面给出的目录和代码用于说明这套工程逻辑,落到真实项目时需要根据安装包的版本和包路径调整。
2.2 创建虚拟环境并安装依赖
先建立项目目录,再创建虚拟环境。
mkdir hermes-agent-lab cd hermes-agent-lab python -m venv .venv source .venv/bin/activateWindows 环境把激活命令换成:
.venv\Scripts\activate安装基础依赖:
python -m pip install --upgrade pip pip install openai python-dotenv pyyamlopenai 是官方 SDK,python-dotenv 用来读取 .env 配置,pyyaml 用来读取 Skill 技能描述文件。如果你的环境不需要 YAML 技能目录,可以暂时不装 pyyaml,但建议保留,后面做技能注册时会更方便。
2.3 项目目录结构与 .env 配置
设计合理的目录结构,能让 Session、Skill、Tool、Context 各归其位。本项目建议如下:
hermes-agent-lab/ ├── .env ├── .env.example ├── requirements.txt ├── app/ │ ├── __init__.py │ ├── main.py │ ├── session.py │ ├── context.py │ ├── skills.py │ └── tools.py ├── data/ │ ├── notes/ │ └── sessions/ └── skills/ ├── meeting_review/ │ ├── skill.yaml │ └── instructions.md └── chat_helper/ └── skill.yaml.env 示例:
HERMES_API_KEY=sk-your-key HERMES_BASE_URL=https://api.openai.com/v1 HERMES_MODEL=gpt-4o-mini HERMES_SESSION_DIR=data/sessions HERMES_NOTE_DIR=data/notes不要提交 .env 到 Git,提交时只保留 .env.example。API Key 必须通过环境变量注入,不要把密钥写死在代码里。如果使用本地大模型服务,把 HERMES_BASE_URL 改成对应的 OpenAI 兼容地址即可,例如:
HERMES_BASE_URL=http://127.0.0.1:8000/v1不同模型对函数调用、JSON 输出和控制符的处理能力不同,切换模型前要先确认模型是否支持原生 tools 参数。
2.4 先验证模型连接,再进入功能开发
在写复杂代码前,先写一段最小连接验证:
import os from dotenv import load_dotenv from openai import OpenAI load_dotenv() client = OpenAI( api_key=os.getenv("HERMES_API_KEY"), base_url=os.getenv("HERMES_BASE_URL"), ) resp = client.chat.completions.create( model=os.getenv("HERMES_MODEL", "gpt-4o-mini"), messages=[ {"role": "system", "content": "你是 Hermes Agent 的连通性测试助手。"}, {"role": "user", "content": "请回复:连接正常"}, ], ) print(resp.choices[0].message.content)这一步能确认 Key、Base URL、模型名三者是否匹配。很多安装和接入问题都出在这个环节,而不是后面的 Agent 代码。
3. Session 会话的代码落地:用 JSON 文件做可恢复的状态存储
3.1 定义 Session 数据结构
为了不过度设计,先把 Session 建模成 dataclass。这里的关键是:messages 必须是结构化列表,每个元素都是 API 可直接消费的字典,而不是把整段对话存成一个大字符串。
在 app/session.py 中写入:
from dataclasses import dataclass, field, asdict import json import os import time @dataclass class Session: session_id: str model: str = "" messages: list[dict] = field(default_factory=list) metadata: dict = field(default_factory=dict) created_at: float = field(default_factory=time.time) updated_at: float = field(default_factory=time.time) def add_user_message(self, content: str): self.messages.append({"role": "user", "content": content}) self.updated_at = time.time() def add_assistant_message(self, content: str): self.messages.append({"role": "assistant", "content": content}) self.updated_at = time.time() def to_dict(self): self.updated_at = time.time() return asdict(self) @classmethod def from_dict(cls, data): return cls(**data)注意不要把 role 之外的自定义字段混进 messages。像“这条消息属于哪个技能”“这条消息的 token 数”这类信息应放入 metadata,而不是消息体里。API 对 messages 有严格结构要求,混合业务字段可能让兼容本地模型的接口直接报错。
3.2 SessionStore:保存、恢复、防止路径穿越
Session 的保存位置由 HERMES_SESSION_DIR 控制。每个会话一个 JSON 文件,文件名使用 session_id。
class SessionStore: def __init__(self, session_dir: str = "data/sessions"): self.session_dir = session_dir os.makedirs(session_dir, exist_ok=True) def _path(self, session_id: str) -> str: if session_id != os.path.basename(session_id): raise ValueError("session_id 不能包含路径分隔符") return os.path.join(self.session_dir, f"{session_id}.json") def save(self, session: Session): path = self._path(session.session_id) tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(session.to_dict(), f, ensure_ascii=False, indent=2) os.replace(tmp, path) def load(self, session_id: str) -> Session: path = self._path(session_id) if not os.path.exists(path): return Session(session_id=session_id) with open(path, "r", encoding="utf-8") as f: data = json.load(f) return Session.from_dict(data)这里有一个容易被忽略的安全细节:session_id 必须经过 os.path.basename 校验,否则恶意调用可能通过 ../../ 之类路径访问其他文件。Session 文件本质是本地数据,但生产环境里如果 session_id 来自外部,这就是路径穿越漏洞入口。
3.3 为什么保存要使用 tmp 文件加 rename
SessionStore.save 先写 .tmp 文件,再调用 os.replace 替换正式文件。直接打开正式文件写入,如果进程中途崩溃或磁盘写入失败,原文件可能变成半截 JSON。
os.replace 在同一个文件系统内是原子操作,要么新内容完全生效,要么旧内容保持不变。这个技巧在保存 Session、技能缓存和知识库索引时都建议保留。
恢复会话时可以这样验证:
store = SessionStore("data/sessions") s = store.load("demo-001") print(len(s.messages)) s.add_user_message("继续上一次话题") store.save(s)多次运行程序后,再次打开还能从 data/sessions/demo-001.json 恢复历史,这就是 Session 持久化的意义。
4. Skill 技能注册和 Context 上下文加载:按需提示比堆指令更可靠
4.1 SkillRegistry:用目录和描述文件注册技能
很多开发者喜欢把所有指令写在 system prompt 里,结果技能一多,提示词首屏被挤爆,模型反而丢失关键信息。
这里用目录方式注册 Skill。每个技能目录里放一个 skill.yaml,描述技能名称、使用条件和工具清单。
例如 skills/meeting_review/skill.yaml:
name: meeting_review description: 当用户要求整理会议纪要、记录项目复盘或保存讨论结论时使用 prompt: | 你是会议与项目复盘助手。用户提出保存或总结需求时,先判断是否涉及已保存笔记。 如果需要查看已有内容,调用 list_notes 和 read_note。 需要落盘时,调用 save_note 写入 Markdown 文件,并给用户返回文件路径。 tools: - list_notes - read_note - save_noteSkillRegistry 的作用是扫描目录、读取 YAML、把可用技能暴露给 ContextBuilder。代码放在 app/skills.py:
import os from dataclasses import dataclass import yaml @dataclass class Skill: name: str description: str prompt: str = "" tools: list[str] | None = None class SkillRegistry: def __init__(self, skill_dir: str = "skills"): self.skill_dir = skill_dir self._skills: dict[str, Skill] = {} def load(self): for root, _, files in os.walk(self.skill_dir): if "skill.yaml" not in files: continue yaml_path = os.path.join(root, "skill.yaml") with open(yaml_path, "r", encoding="utf-8") as f: data = yaml.safe_load(f) skill = Skill( name=data["name"], description=data.get("description", ""), prompt=data.get("prompt", ""), tools=data.get("tools", []), ) self._skills[skill.name] = skill return self def get_skill_text(self, skill_names: list[str] | None = None) -> str: lines = [] for name, skill in self._skills.items(): if skill_names and name not in skill_names: continue lines.append(f"技能名:{skill.name}") lines.append(f"适用场景:{skill.description}") lines.append(f"执行说明:\n{skill.prompt}") return "\n\n".join(lines)在这个实现里,技能说明是否注入到 System prompt,由 skill_names 参数决定。最简单的激活策略是把所有技能说明都注入;更精细的做法是在用户输入前先做一次轻量分类,只激活相关技能。
4.2 ContextBuilder:把会话历史、技能说明、用户输入组装成一次请求
ContextBuilder 需要解决两个问题:历史顺序不能乱,上下文长度不能超。代码放在 app/context.py:
class ContextBuilder: def __init__(self, base_system_prompt: str, max_history_chars: int = 8000): self.base_system_prompt = base_system_prompt self.max_history_chars = max_history_chars @staticmethod def _estimate_cost(message: dict) -> int: text = message.get("content") if isinstance(text, str): return len(text) + 64 return 128 def build(self, session, user_input: str, skill_text: str = "", extra_context: str = "") -> list[dict]: system_prompt = self.base_system_prompt if skill_text: system_prompt += "\n\n可用技能说明:\n" + skill_text if extra_context: system_prompt += "\n\n检索参考内容:\n" + extra_context messages = [{"role": "system", "content": system_prompt}] recent = [] used = 0 for m in reversed(session.messages): cost = self._estimate_cost(m) if used + cost > self.max_history_chars: break recent.append(m) used += cost recent.reverse() messages.extend(recent) messages.append({"role": "user", "content": user_input}) return messages这段代码里最关键的是 max_history_chars 预算。历史消息从后往前选择,优先保留最近的对话;从前往后截断会丢掉用户刚刚表达的意图,导致模型出现“失忆感”。
4.3 上下文加载里不要忽略技能说明的排序
System prompt 建议顺序是:角色定位、任务规则、技能说明、工具使用约束、输出格式、安全边界。
技能说明不能放在用户消息之后。如果模型已经读完一条具体用户请求,才知道“你拥有笔记工具”,它对工具的感知会明显降低。Agent 开发和 Prompt 调试的核心原则是:先给模型完整操作手册,再让模型看到具体任务。
5. Tool 工具调用闭环:从模型输出结构化参数到真实执行
5.1 先注册安全、可验证的本机工具
工具选择对演示效果很关键。这里选择三个笔记类工具,避免引入网络请求和系统命令,降低教学风险。代码放在 app/tools.py:
import json import os import re from pathlib import Path class ToolRegistry: def __init__(self): self._handlers = {} self.schemas = [] def register(self, name, description, parameters, handler): self._handlers[name] = handler self.schemas.append({ "type": "function", "function": { "name": name, "description": description, "parameters": parameters, }, }) def execute(self, name: str, arguments: dict): if name not in self._handlers: raise KeyError(f"未知工具:{name}") return self._handlers[name](**arguments) def has_tools(self) -> bool: return len(self.schemas) > 0再定义三个实际函数:
def _safe_note_path(note_dir: str, title: str) -> Path: filename = re.sub(r"[^a-zA-Z0-9_-]", "_", title) + ".md" path = Path(note_dir) / filename return path def list_notes(note_dir: str) -> dict: path = Path(note_dir) path.mkdir(parents=True, exist_ok=True) files = [p.name for p in path.glob("*.md")] return {"ok": True, "count": len(files), "files": files} def save_note(note_dir: str, title: str, content: str) -> dict: path = _safe_note_path(note_dir, title) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") return {"ok": True, "path": str(path)} def read_note(note_dir: str, title: str) -> dict: path = _safe_note_path(note_dir, title) if not path.exists(): return {"ok": False, "error": "笔记不存在"} return {"ok": True, "content": path.read_text(encoding="utf-8")}save_note 使用正则把标题里的特殊字符替换成下划线,防止路径穿越。工具函数返回 dict 的好处是结构清晰,能直接写入 Tool 消息。
在主程序中完成注册:
registry = ToolRegistry() registry.register( name="list_notes", description="列出笔记目录下所有 Markdown 笔记文件名", parameters={ "type": "object", "properties": {}, }, handler=lambda: list_notes(NOTE_DIR), ) registry.register( name="save_note", description="把内容保存为一条 Markdown 笔记,标题只用字母数字下划线连字符", parameters={ "type": "object", "properties": { "title": { "type": "string", "description": "笔记标题,不带扩展名", }, "content": { "type": "string", "description": "Markdown 正文", }, }, "required": ["title", "content"], }, handler=lambda title, content: save_note(NOTE_DIR, title, content), )工具描述写得好不好,直接决定模型调用准确率。描述要写清触发条件、参数格式、边界限制,不能只写“保存笔记”。没有说明文件路径规则时,模型可能生成带斜杠的标题。
5.2 AgentRuntime:发起请求、接收工具调用、回填结果、循环收敛
工具调用的本质是循环,不是单次请求。第一轮模型可能返回 tool_calls,Agent 执行后把结果追加进去,再发起第二轮请求。代码可以直接写在 app/main.py:
import json from openai import OpenAI class AgentRuntime: def __init__(self, client: OpenAI, session, store, context_builder, tool_registry, model: str): self.client = client self.session = session self.store = store self.context_builder = context_builder self.tools = tool_registry self.model = model def run(self, user_input: str) -> str: messages = self.context_builder.build( self.session, user_input, skill_text="", ) while True: kwargs = {"model": self.model, "messages": messages} if self.tools.has_tools(): kwargs["tools"] = self.tools.schemas resp = self.client.chat.completions.create(**kwargs) assistant_msg = resp.choices[0].message tool_calls = getattr(assistant_msg, "tool_calls", None) assistant = { "role": "assistant", "content": assistant_msg.content or "", } if tool_calls: assistant["tool_calls"] = [] for tc in tool_calls: assistant["tool_calls"].append({ "id": tc.id, "type": "function", "function": { "name": tc.function.name, "arguments": tc.function.arguments, }, }) messages.append(assistant) if not tool_calls: self.session.add_user_message(user_input) self.session.add_assistant_message(assistant_msg.content or "") self.store.save(self.session) return assistant_msg.content or "" for tc in tool_calls: try: args = json.loads(tc.function.arguments or "{}") result = self.tools.execute(tc.function.name, args) result_payload = {"ok": True, "data": result} except Exception as exc: result_payload = {"ok": False, "error": str(exc)} messages.append({ "role": "tool", "tool_call_id": tc.id, "content": json.dumps(result_payload, ensure_ascii=False), })这段循环有几个重要工程点:
- 工具执行失败不要直接抛异常,应该把错误信息作为 tool 消息返回给模型,让模型决定是修正参数重试,还是向用户说明失败。
- 每次循环都要把 assistant 消息先放进 messages,再放 tool 消息,顺序反了模型会把工具结果挂到错误对话上。
- 只有最终得到普通回复时才写入 Session,工具调用中间过程不长期保存,否则 Session 文件会迅速膨胀。
5.3 完整的 CLI 入口
在 app/main.py 最后补上入口:
def main(): from app.session import SessionStore from app.context import ContextBuilder from app.tools import ToolRegistry session_store = SessionStore("data/sessions") session = session_store.load("demo-001") session.model = MODEL registry = ToolRegistry() # 注册三个笔记工具 builder = ContextBuilder( base_system_prompt="你是运行在本地的 Hermes Agent 助手,使用中文回答,工具返回值作为辅助事实。", max_history_chars=6000, ) runtime = AgentRuntime( client=client, session=session, store=session_store, context_builder=builder, tool_registry=registry, model=MODEL, ) print("Hermes Agent 已启动,输入 exit 退出") while True: user_input = input("你 > ").strip() if user_input.lower() in ("exit", "quit"): break answer = runtime.run(user_input) print("Agent >", answer) if __name__ == "__main__": main()这个入口不是为了“能跑就行”,而是让每个组件都有明确生命周期。Session 在程序启动时加载,Context 按轮构建,Tool 使用全局注册表,模型调用集中在 AgentRuntime。真实项目在这些类之间补充日志、监控和配置中心时会更顺畅。
6. 运行验证:通过一次“保存笔记再读取”确认完整链路
6.1 预期运行过程
启动程序前先用命令行确认环境变量已加载:
python -c "from dotenv import load_dotenv; load_dotenv(); import os; print(bool(os.getenv('HERMES_API_KEY')))"输出 True 表示 .env 配置读取成功。然后执行:
python -m app.main第一次输入:
你 > 请保存一条 Session 使用笔记,标题是 session_os_replace,内容是:保存会话时要先写 tmp 文件再用 os.replace 替换。模型会先返回一次工具调用。可以打印日志确认类似信息:
[ToolCall] save_note title: session_os_replace content: 保存会话时要先写 tmp 文件再用 os.replace 替换。 [ToolResult] {'ok': True, 'path': 'data/notes/session_os_replace.md'} Agent > 已保存到 data/notes/session_os_replace.md继续输入:
你 > 读取刚才那条笔记,并说明里面讲了什么这次模型会调用 read_note,再基于返回内容总结。
6.2 Session 文件验证
程序退出后查看会话目录:
ls data/sessions cat data/sessions/demo-001.json文件里应包含 user 消息和 assistant 消息,工具调用过程不在 messages 中持久化。这是有意设计,避免 Session 文件被工具输出撑爆。
6.3 验证检查清单
| 检查项 | 预期结果 | 失败时排查方向 |
|---|---|---|
| API 连通 | 能返回一次普通回复 | Key、Base URL、模型名 |
| Tool schema 生效 | 模型不要求说明也会自动调用 save_note | 工具描述不清晰、模型不支持原生 tools |
| Tool 参数正确 | 生成的标题不含斜杠和空格 | 参数 description 要明确规则 |
| Tool 结果回填 | 模型能引用保存成功后的文件路径 | 检查 tool_call_id 是否与 assistant 消息一致 |
| Session 保存 | 再次启动后能读取历史消息 | 检查 data/sessions 权限和 JSON 文件是否完整 |
| Context 截断 | 长历史不会导致 context length exceeded | 调低 max_history_chars,或引入摘要压缩 |
完整的验证不是只确认“程序启动了”,而是确认模型确实感知到工具返回结果,并且能把结果用于下一轮回答。如果模型执行完 save_note 后仍然回答“我不知道你有没有保存成功”,说明 Tool 消息回填链路有问题,优先检查消息顺序和 tool_call_id。
7. 常见问题与排查路径
7.1 安装或首次启动时要求登录、网页授权
不少 Agent 产品在安装后第一次启动时,会要求打开网页完成账号绑定或 API Key 授权。这不是安装失败,而是产品把用户身份和模型服务绑定在一起。
处理顺序:
- 完整查看启动页提示的链接,不要在终端里反复回车跳过。
- 打开网页完成登录后,回到应用点击“已完成授权”或重启程序。
- 如果显示授权成功但状态未变,检查安装目录、系统代理、防火墙设置。
- 如果只想学习 Agent 工程原理,可以直接用本文这类纯 Python 工程,不依赖桌面安装包。
7.2 报错 context length exceeded
这个错误出现时,模型输入超过了窗口长度。先看报错发生在哪一步:如果发生在知识库内容加载后,说明检索片段太长;如果发生在多轮对话后期,说明 Session 历史没做截断。
解决方法是在 ContextBuilder 中压缩历史,同时限制检索片段返回长度。需要注意中文 token 估算不能用简单字符数,但本地先用字符数做粗粒度预算,再用实际 token 数调整,是性价比最高的改法。
7.3 Tool 调用结果看起来没问题,但模型还在乱编
出现这种现象,通常不是因为模型笨,而是模型没有看到完整结果。原因集中在三处:
- Tool 返回内容过长被截断,模型只看到了前半段。
- Tool 消息和 assistant tool_call 没有正确对应。
- 工具结果只写在日志里,没有作为消息发回模型。
检查时把消息列表完整打印出来,按角色和顺序检查:
system user assistant(tool_calls) tool(tool_call_id=xxx) assistant(final)只要 assistant tool_call 后缺少 tool 消息,模型在下一轮就只能猜测工具执行结果。
7.4 Session 文件保存失败或 JSON 损坏
先检查 data/sessions 目录是否存在、进程是否有写权限。再查看代码是否直接覆盖正式文件,强烈建议保留 tmp 加 os.replace 的写法。如果并发场景需要多个线程或进程同时写同一 Session,还需要引入文件锁或改为数据库存储,不能只在单进程脚本里做一次性覆盖。
| 问题现象 | 常见原因 | 处理建议 |
|---|---|---|
| 安装环境后 import openai 失败 | 虚拟环境未激活或依赖没装好 | 执行 pip list 检查,重新激活 venv |
| API Key 读取为空 | .env 不在当前目录或未调用 load_dotenv | 打印 os.getenv 结果,确认工作目录 |
| 模型返回内容中文乱码 | 系统默认编码问题 | 文件统一 UTF-8,终端切换 UTF-8 |
| 工具参数在中文标题下报错 | 文件系统非法字符 | 用白名单规则替换符号,禁止直接拼路径 |
| 多次运行后 Session 暴涨 | 只做追加不做压缩 | 设置历史条数和字符数上限 |
8. 部署和生产化建议:从能 demo 到能上线,中间还差四件事
8.1 学习环境与生产环境的差异
| 能力 | 学习环境 | 生产环境 |
|---|---|---|
| 配置 | .env 固定读本地 | 配置中心或环境变量注入 |
| Session 存储 | JSON 文件 | Redis、PostgreSQL,支持多实例 |
| 日志 | 结构化日志,带请求和会话 ID | |
| 安全 | 本地信任 | 鉴权、限流、审计、密钥加密 |
| 上下文 | 粗粒度截断 | Token 预算 + 历史摘要 + 向量检索 |
| 发布 | 源码直接运行 | 容器化、镜像版本、回滚方案 |
minimal demo 能跑通,不代表可以直接暴露到公网。生产环境里 Session 文件按用户名隔离、Tool 白名单、文件路径校验、模型输出敏感词过滤、操作审计,每一条都不能少。
8.2 Skill 和 Tool 的扩展原则
Skill 越少越精确。每新增一个技能,都要评估它是否增加了信息量,还是只是重复了其他