☰
DeepSeek Harness:全插件化Agent框架与可回放日志设计
2026/10/22 16:20:09 网站建设 项目流程

1. 项目概述:从混乱的工具链到可掌控的 Agent 框架

先聊聊我为什么要做这个东西。过去大半年里,我一直在折腾各种 Agent 应用,从最开始的“调一个 LLM API 完事”,到后面开始接触 ReAct、Function Calling、多工具协同、多 Agent 协作这些概念。说实话,模型能力进步很快,但工程化落地一直是老大难。我见过太多项目死在同一个地方——刚开始跑通 demo 的时候欢天喜地,一旦要接真实业务、要处理各种异常分支、要调试 Agent 在一次对话里到底调了哪些工具、传了什么参数、为什么最后答非所问,就彻底抓瞎。

这个项目的核心,是围绕DeepSeek Harness这个内部代号展开的。它不是要做一个新的 Agent 平台,而是要解决两个非常具体的工程问题:第一,Agent 的工具调用链能不能像搭积木一样,按需插拔、动态扩展,而不是每次加个新能力就要改核心代码?第二,Agent 的一次会话能不能像录像机一样,把每一步都记录下来,并且支持精确回放到某一步,方便定位问题?

一句话概括:我要给 Agent 业务代码做一次“工程化手术”,核心是“全插件化设计”和“可回放会话日志”这两项。

这篇博文适合谁看?适合那些已经用 LangChain、LlamaIndex 或者其他框架写过 Agent 原型,但在生产环境里被工具注册混乱、日志不可读、Bug 难复现折磨过的开发者。也适合准备自建 Agent 框架的团队,我可以明确告诉你哪些设计值得借鉴、哪些代码是我踩坑踩出来的教训。我不会讲大而全的架构理论,只讲我自己怎么落地、怎么避坑、怎么在“灵活”和“可控”之间找平衡。

围绕这个项目标题,我要认真拆解三件事:为什么要全插件化、全插件化具体怎么做、可回放日志到底怎么设计才能真有用。

2. 全插件化设计:为什么非做不可,以及怎么做到位

2.1 传统 Agent 工具链的痛点

在讲插件化之前,我想先把传统做法的问题掰开揉碎讲清楚,不然你不会理解我为什么要这么大动干戈。

我早期做 Agent 的时候,代码结构大概是这样的:一个agent.py,里面写死了一堆if-elif分支,每个分支对应一个工具调用。比如用户说查天气,我就if '天气' in query: call_weather_api();用户说算个税,我就elif '税' in query: call_tax_calculator()。最开始工具只有三四个,这么写没毛病。可当工具数量涨到十几个、几十个的时候,这个文件就成了一个几千行的“屎山”。每次加一个工具,就要动核心逻辑,要重新跑一遍所有历史用例,生怕哪个if分支的顺序搞错了导致误触发。

更麻烦的是多 Agent 协作的场景。假设我有个主 Agent,下面挂了“搜索助手”“代码助手”“数据分析助手”三个子 Agent,每个子 Agent 又挂着自己的工具列表。如果用传统写法,Agent 之间的工具调用关系会变成一团乱麻——很可能出现搜索助手越权调用了代码助手的工具,或者主 Agent 根本不知道某个子 Agent 到底有哪些能力。

当时我复盘了一下,这些问题归根结底是耦合太重。工具与 Agent 核心逻辑耦合、工具与工具之间的注册关系耦合、工具的输入输出格式与业务逻辑耦合。想要根治,就必须引入插件机制。

2.2 插件协议设计:定义每个工具的“接口契约”

做插件化设计,最大的坑就是“为了插件化而插件化”,把代码拆得到处都是,结果可维护性反而更差了。我见过有人做插件化,直接把每个工具封装成一个类,然后互相import,这根本不是插件化。真正的插件化,核心是运行时发现与注册,而不是代码组织上的“拆文件”。

我的做法是给所有工具定义一个统一的协议,我把它叫做ToolSpec。每个插件都必须实现这个协议,才能被框架加载。它的核心定义简化后大概是这样的:

# framework/plugin/protocol.py from dataclasses import dataclass, field from typing import Any, Callable, Optional, Dict import jsonschema @dataclass class ToolParam: name: str type: str # string, number, boolean, object, array required: bool = False description: str = "" @dataclass class ToolSpec: name: str # 全局唯一的工具名,如 "weather.query" version: str = "1.0.0" description: str = "" # 面向 LLM 的自然语言描述,决定模型何时调用 params: list[ToolParam] = field(default_factory=list) handler: Callable[..., Any] = None # 实际执行函数 tags: list[str] = field(default_factory=list) # 分类标签 def json_schema(self) -> dict: """把协议自动转换成 LLM Function Calling 需要的 JSON Schema""" props = {} required = [] for p in self.params: props[p.name] = { "type": p.type, "description": p.description, } if p.required: required.append(p.name) return { "type": "object", "properties": props, "required": required, } def validate_args(self, args: dict) -> None: """用 jsonschema 校验模型传参数是否正确,这一步会避免很多线上灾难""" jsonschema.validate(instance=args, schema=self.json_schema())

这个协议看起来简单,但里面有几个细节我是反复琢磨过的:

第一,name必须是全局唯一的,并且要有命名空间前缀。比如weather.query和weather.alerts,而不是query_weather_1这种。为什么?因为当插件数量多了之后,你靠名字就能知道这个工具属于哪个插件包,而且字符串前缀天然支持按插件模块批量管理。

第二,description是给 LLM 看的,不是给人看的。这是很多新手最容易忽略的地方。模型决定调不调某个工具,基本就看这段描述。所以描述要写清楚“什么场景下用这个工具”“用这个工具能获得什么信息”。比如我写过search.web的描述是“在互联网上检索公开信息。注意:只能在需要实时信息、最新动态或超出模型训练截止日期的事实时才使用此工具。”而不是简单地写“搜索工具”。

第三,参数校验前置。这里我用了jsonschema,效果非常好。模型经常出现幻觉参数,比如把日期格式传成2024/1/1而不是2024-01-01,或者把整数参数传成字符串。有了validate_args,这类问题在进入业务代码之前就被挡住了,你可以统一返回一条“参数格式错误,请参考 JSON Schema 重新组织参数”的消息给模型,让它自己纠错。这比业务代码里到处写try-except强一百倍。

2.3 插件加载与生命周期管理:热插拔不是开玩笑

定义好协议之后,下一步是解决“怎么加载”和“怎么卸载”的问题。我在框架内部做了一个PluginRegistry(插件注册中心),它负责维护两张表:一张是工具名到 ToolSpec 的映射表,另一张是插件包到工具列表的映射表。

核心加载逻辑我有意做得非常“笨”:

# framework/plugin/registry.py import importlib import inspect from typing import Dict, List, Optional from .protocol import ToolSpec class PluginRegistry: def __init__(self): self._tools: Dict[str, ToolSpec] = {} self._packages: Dict[str, List[str]] = {} def load_plugin(self, package_name: str) -> int: """加载一个插件包,返回成功注册的工具数量""" module = importlib.import_module(package_name) if not hasattr(module, "register"): raise ValueError(f"插件包 {package_name} 缺少 register(registry) 入口函数") before = len(self._tools) module.register(self) added = len(self._tools) - before self._packages[package_name] = self._list_tools_added_since(before) return added def register_tool(self, spec: ToolSpec) -> None: if not isinstance(spec, ToolSpec): raise TypeError("只能注册 ToolSpec 实例") if spec.name in self._tools: raise KeyError(f"工具名冲突: {spec.name},请检查插件包是否重复加载") self._tools[spec.name] = spec def get_tool(self, name: str) -> Optional[ToolSpec]: return self._tools.get(name) def all_tools(self) -> List[ToolSpec]: return list(self._tools.values()) def unload_plugin(self, package_name: str) -> int: """卸载插件包,生产环境热更新时很有用""" if package_name not in self._packages: return 0 names = self._packages[package_name] for n in names: self._tools.pop(n, None) del self._packages[package_name] return len(names) def _list_tools_added_since(self, start: int): return list(self._tools.keys())[start:]

你看,这里有一个非常关键的约定:每个插件包必须暴露一个register(registry)函数。这个约定是整个框架能够“热插拔”的基石。我把“插件内部有哪些工具”这件事完全交给了插件自己声明,框架本身不关心插件内部怎么组织代码。只要接口对得上,加载就是一行load_plugin("plugins.weather")的事;卸载也是对称的一行。

实际使用中我会配合 Python 的importlib反射机制,让支持热更新的工具包直接通过模块路径加载。比如我在线上加了一个“汇率查询”插件,只需要把写好的插件包上传到服务器,然后调用一次registry.load_plugin("plugins.exchange_rate"),之前已经运行中的 Agent 会话在下一轮迭代时就能立刻感知到新工具的存在。不需要重启进程,不需要人工改配置。

这里有一个我从实践中总结的注册中心设计铁律:框架永远不应该硬编码任何具体工具名。也就是说,你在核心代码里绝不应该写if tool_name == "weather.query"这样的分支。一旦你写了,插件化就变成了“伪插件化”。正确的做法是,核心逻辑只依赖协议(ToolSpec),具体工具是谁,全部交给注册中心来回答。

2.4 事件总线:让工具调用不再孤军奋战

全插件化的第二个关键设计是事件总线(EventBus)。为什么要加这个东西?因为在 Agent 运行过程中,工具调用并不是孤立的事件。工具执行前可能要鉴权,执行中要记录耗时和 Token 消耗,执行后要通知日志系统、要触发缓存更新。如果这些事情都写在每个工具的 handler 里面,那每个工具都要重复实现一遍,而且很容易漏掉某个环节。

我的设计思路是给工具调用挂上一组“钩子”,或者说是在工具执行链路的前中后分别广播事件:

# framework/plugin/events.py from collections import defaultdict from typing import Callable, Any, Dict from enum import Enum class EventType(str, Enum): BEFORE_TOOL_CALL = "before_tool_call" AFTER_TOOL_CALL = "after_tool_call" TOOL_ERROR = "tool_error" TOOL_TIMEOUT = "tool_timeout" AGENT_STEP_START = "agent_step_start" AGENT_STEP_END = "agent_step_end" class EventBus: def __init__(self): self._handlers: Dict[str, list] = defaultdict(list) def subscribe(self, event: EventType, handler: Callable[..., Any]): self._handlers[event.value].append(handler) return lambda: self._handlers[event.value].remove(handler) def publish(self, event: EventType, **payload: Any): for handler in self._handlers.get(event.value, []): handler(**payload)

这个EventBus看上去简单,但它帮我把很多横切关注点从业务工具里剥离了出来。举个例子,我给框架写了一个metrics_handler,订阅了BEFORE_TOOL_CALL和AFTER_TOOL_CALL,自动记录每个工具的名字、参数大小、执行耗时。又写了一个audit_handler,订阅TOOL_ERROR,把所有异常工具有没有重试等等信息写进审计日志。这些 handler 全部通过配置项加载,工具业务代码完全无感知。

2.5 插件化的另一重价值:标准化输入输出

还有一个插件化设计中容易被忽视的关键点——所有工具的执行结果必须统一包装成ToolResult对象,而不是“裸返回”。最初我把这个设计当成“形式主义”,后来发现这是保证整个框架稳定性的核心之一。

# framework/plugin/protocol.py @dataclass class ToolResult: tool_name: str success: bool = True data: Any = None error_message: str = "" duration_ms: float = 0.0 metadata: Dict[str, Any] = field(default_factory=dict)

为什么要统一?因为 Agent 的核心循环本质上是LLM 与工具之间的对话。如果工具 A 返回一个 dict,工具 B 返回一个字符串,工具 C 直接抛异常,你就必须在调用工具之后写大量“格式转换”代码来适配 LLM。而统一成ToolResult之后,我可以在框架内部把这些结果统一序列化成给 LLM 看的文本:

工具执行结果: - 工具名: weather.query - 状态: 成功 - 数据: {"city": "北京", "temp": 26, "humidity": 40} - 耗时: 320ms

这样 LLM 每次看到的工具返回格式都是一致的,它就不容易“学歪”,也不容易在上下文里迷失。同时,ToolResult也天然为后面的可回放日志提供了基础数据结构。

3. 可回放会话日志:Agent 调试的最后一根救命稻草

3.1 Agent 调试为什么这么难

如果你只写过普通 CRUD 接口,你可能会觉得“日志不就是 print 吗,有什么好设计的”。但 Agent 应用的调试难度和普通应用完全不在一个量级。

普通应用的执行路径是确定的:请求进来,方法 A 调方法 B,B 调 C,出错就是某个栈。Agent 应用的执行路径是由模型动态决定的:同样是“帮我查一下今天的天气和明天的机票价格”,模型可能第一轮先调天气工具,也可能先调机票搜索工具,甚至可能第一轮什么都不调,而是先反问用户“您想从哪个城市出发”。这个不确定性意味着,你根本没法用传统的“复现-定位”思路去调试 Agent Bug。

我遇到过的一个典型场景是这样的:某个用户反馈“Agent 回答得不对”,我打开服务端日志,发现自己只记录了“用户问了一句什么话,Agent 最终回答了什么”,中间的过程完全空白。我不知道模型到底有没有调工具、调的是哪个工具、传了什么参数、工具返回了什么、模型又是基于哪个结果答的。这种情况下,我唯一的办法就是拿着用户的原话去重新跑一遍 Agent。可问题是,模型有随机性,而且第三方工具的状态是动态的,重跑出来的结果和用户当时看到的情况早就不是一回事了。

所以做可回放日志这件事,不是“锦上添花”,而是Agent 上生产的刚需。

3.2 回放日志的数据模型:状态快照 + 事件流

要支持“精确回放”,日志系统需要记录的内容比普通日志要多得多。我设计的核心数据模型是这样一套组合:

第一层:会话(Session)级元信息。包括会话 ID、用户 ID、开始时间、结束时间、模型版本、Agent 配置版本、插件包版本列表。这些是定位一个会话“当时用的什么环境”的必要信息。

第二层:状态快照(Snapshot)。这里的状态指的不是 Agent 的内部内存——因为我们一般不把 LLM 的内部状态当作可持久化对象——而是指:每一个时间点上,Agent 的完整上下文状态,包括系统提示词、用户输入、每一轮的模型输出、工具调用参数、工具返回结果、累计消耗 Token 数。这些快照记录了“模型在那一刻看到了什么”。

第三层:事件流(Event Stream)。这层记录的是“发生了什么”。相比状态快照,事件流更偏向行为记录:什么时候发起了 LLM 请求、模型返回用了多少秒、工具调用是否超时、有没有重试、用户有没有中途打断、Agent 是从哪一步开始进入死循环的...

我最后落地的是一个叫ReplayLog的结构化日志模块,直接把事件流与状态快照写进同一个 JSONL 文件。每行是一个事件,每个事件自带自增序号、时间戳和完整数据:

{"session_id": "sess_20240115_AB12", "seq": 1, "type": "agent_step_start", "timestamp": "2024-01-15T10:00:00.123Z", "payload": {"step": 1, "input": "帮我看下明天北京天气"}} {"session_id": "sess_20240115_AB12", "seq": 2, "type": "llm_request", "timestamp": "2024-01-15T10:00:00.130Z", "payload": {"model": "deepseek-chat", "messages": [...], "temperature": 0.3}} {"session_id": "sess_20240115_AB12", "seq": 3, "type": "tool_call", "timestamp": "2024-01-15T10:00:01.200Z", "payload": {"tool": "weather.query", "input": {"city": "北京", "date": "2024-01-16"}}} {"session_id": "sess_20240115_AB12", "seq": 4, "type": "tool_result", "timestamp": "2024-01-15T10:00:01.580Z", "payload": {"tool": "weather.query", "result": {...}}}

你看,这样的日志把 Agent 的每一步都按顺序钉在了时间轴上。要排查问题,只需要按session_id过滤出这整个文件,然后按seq排序,就可以像看“监控回放”一样把整段会话还原出来。

3.3 日志存储与性能:别让日志系统拖垮 Agent

设计这套日志系统时,我最担心的事情是:性能损耗太大,导致 Agent 响应变慢,尤其是在高并发场景下不能成为瓶颈。

我最终选定的方案是异步写盘。所有日志事件先打进一个内存队列(queue.Queue),由后台线程批量写入 JSONL 文件。每 100 条事件或者每 500ms flush 一次。这样写入日志对 Agent 主业务的延迟影响几乎可以忽略。这里刻意没有用同步的 SQLite 或 MySQL,因为在高频小数据量写入场景下,同步数据库 IO 很容易变成瓶颈。

对于日志的格式,我强烈推荐 JSONL 而不是单个大 JSON。单个大 JSON 文件在会话很长时会因为一次写入过多而卡住,而且不方便用标准命令行工具做 streaming 分析。JSONL 的好处是每一行都是完整自描述的,可以很方便地和 Logstash、jq、grep 集成。

如果说你在生产环境的环境审计要求更严格,可以考虑额外加一层哈希链:每一行的内容包含上一行的哈希值。这样如果有人事后篡改日志,哈希链校验立刻会报警。我自己的项目因为还没有这么强的安全审计需求,目前只是预留了这个接口,没有默认开启。

3.4 回放执行器:拷问历史会话,复现每一步

有了日志,最后一步是“回放”。这里的回放不是简单把日志打印给人看(虽然打印给人看也有用),而是把历史会话的输入数据重新喂给 Agent 框架,但要切断真实外部依赖,用日志里记录的工具结果来“代答”。

我实现了一个叫ReplayEngine的组件。它的核心哲学是:回放时绝不真正调用外部工具。所有外部工具调用都从日志里查表获得结果。这样做的原因很简单——外部世界已经变了。你无法保证天气 API 还能返回和那天一模一样的温度,也无法保证搜索 API 还能返回一样的网页。但你要调试的并不是“天气今天是多少度”,而是“Agent 在那一天的上下文里为什么做了那个决定”。所以回放时工具返回的数据必须“冻结”在日志时刻。

回放引擎的逻辑如下:

# framework/replay/engine.py from typing import Dict, Any, Optional import queue class ReplayEngine: def __init__(self, log_events: list[dict], registry): self._events = {} self._build_index(log_events) self._registry = registry def _build_index(self, log_events): for ev in log_events: key = (ev["seq"], ev["type"]) self._events[key] = ev["payload"] if ev["type"] == "tool_result": tool_name = ev["payload"]["tool"] self._tool_results[(tool_name, ev["seq"])] = ev["payload"]["result"] def replay(self, start_seq: int = 1, end_seq: int | None = None): """从指定步骤开始重新执行,所有工具调用改走日志缓存""" # 把历史 events 注入到当前 Agent 运行时的 EventBus # 订阅 BEFORE_TOOL_CALL 事件,在真实调用之前拦截并返回日志中记录的结果 pass

这里最巧妙的是,我复用了第 2.4 节介绍的事件总线。正常运行时,工具调用走的是真实 handler;回放时,我在BEFORE_TOOL_CALL事件中插入一个拦截器,一旦发现当前请求的工具名和参数与日志中对得上,就直接从日志缓存返回结果,跳过真实执行。Agent 主流程对这一切无感知,它只知道自己调了工具、工具给了结果、然后继续 inference。

这个设计的价值是巨大的。比如线上出现了一个“Agent 在第二步就死循环”的 bug,我可以用回放引擎从第一步开始跑,到第二步时强制改变模型参数(比如temperature=0)或者跳过某一步的用户输入,观察系统在哪种干预下能恢复。甚至可以在回放引擎中挂上pdb断点,在特定工具调用前后暂停,审查 Agent 内部的每一步推理。这种调试体验,比传统“蒙着头重跑”不知道高到哪里去了。

3.5 快照式回放 vs 演算差异

有几个读者可能已经想到的问题,我这里可以先回答:

问题:如果 LLM 本身有随机性,回放的时候模型输出会不会和线上不一致?

会。正因为这个原因,回放引擎设计懒惰的容错模式。如果回放时模型输出和日志记录的不一样,系统会把它标记为“分叉事件”,然后选择:继续往下执行(看看新的分支会引发什么问题),或者强制对齐到日志里的历史输出(严格回放)。具体选哪种取决于你要排查什么问题。排查工具链配置就用强制对齐,排查模型提示词质量问题就用继续执行。

问题:如果 Agent 使用了外部文件读写、网络请求等副作用,回放怎么处理?

我在 Design 阶段就把所有工具调用统一收口到ToolResult上,所以回放引擎可以从日志里精确还原每次调用的入参与结果,不会真正触发副作用。但如果某个插件绕过统一协议直接requests.get(),回放引擎就无能为力了。这正是我强调“所有工具必须走协议、不能裸写副作用”的原因之一。

4. 实操过程与核心实现:从零构建一个可插拔的 Agent

4.1 项目结构

为了让你不迷失在细节里,我先把完整的目录结构给出来,然后逐步讲解每个模块的职责:

deepseek_harness/ ├── agent/ │ ├── __init__.py │ ├── core.py # Agent 主循环 │ ├── planner.py # 任务规划与分解 │ └── executor.py # 工具执行调度器 ├── framework/ │ ├── __init__.py │ ├── plugin/ │ │ ├── __init__.py │ │ ├── protocol.py # ToolSpec, ToolResult │ │ ├── registry.py # PluginRegistry │ │ └── events.py # EventBus │ ├── log/ │ │ ├── __init__.py │ │ ├── recorder.py # AsyncLogRecorder(异步日志记录器) │ │ └── replay.py # ReplayEngine │ └── llm/ │ ├── __init__.py │ ├── client.py # LLM 客户端封装 │ └── message.py # 消息结构 ├── plugins/ │ ├── weather/ │ │ ├── __init__.py │ │ └── main.py │ ├── search/ │ │ ├── __init__.py │ │ └── main.py │ └── calculator/ │ ├── __init__.py │ └── main.py ├── examples/ │ ├── basic_agent.py │ └── replay_debug.py ├── config/ │ ├── default.yaml │ └── plugins.yaml └── requirements.txt

agent/core.py是主循环,它的实现思路是:

# agent/core.py from typing import Any, Dict, List class AgentCore: def __init__(self, registry, event_bus, llm_client, logger): self._registry = registry self._events = event_bus self._llm = llm_client self._log = logger async def run(self, session_id: str, user_input: str, max_steps: int = 10): messages = [{"role": "user", "content": user_input}] for step in range(1, max_steps + 1): self._events.publish("agent_step_start", session_id=session_id, step=step, messages=messages) # 1. 调用 LLM,让它决定下一步动作 response = await self._llm.chat_completion(messages, tools=self._build_tool_schemas()) self._events.publish("llm_request", session_id=session_id, step=step, messages=messages, response=response) # 2. 如果模型要求调用工具 if response.tool_calls: for call in response.tool_calls: spec = self._registry.get_tool(call.name) if not spec: err_result = ToolResult(tool_name=call.name, success=False, error_message="未知工具") else: try: spec.validate_args(call.arguments) result = await self._execute_safely(spec, call.arguments) except Exception as e: result = ToolResult(tool_name=call.name, success=False, error_message=str(e)) self._events.publish("tool_result", session_id=session_id, step=step, tool=call.name, result=result) # 把工具结果追加到消息里,让模型继续推理 messages.append({ "role": "tool", "tool_call_id": call.id, "content": self._format_result(result) }) else: # 3. 模型没有要求调工具,说明它准备好了最终回答 return response.content self._events.publish("agent_step_end", session_id=session_id, step=step, messages=messages) raise TimeoutError(f"Agent 超过 {max_steps} 步仍未终止")

这段代码是框架的心脏。它不依赖任何具体工具,不写死任何业务逻辑。它只做四件事:请求模型、执行工具、记录事件、迭代循环。这也是“全插件化设计”最直观的体现——核心是稳定的,变化都发生在插件层。

4.2 一个最小化插件的完整示例

为了让“插件化”这个概念不那么抽象,我用一个最简单的“计算器”插件来演示完整写法:

# plugins/calculator/main.py from framework.plugin.protocol import ToolSpec, ToolParam, ToolResult def register(registry): registry.register_tool(ToolSpec( name="calculator.add", version="1.0.0", description="两个数字相加。常用于数学运算场景,例如价格计算、数量统计。", params=[ ToolParam(name="a", type="number", required=True, description="第一个加数"), ToolParam(name="b", type="number", required=True, description="第二个加数"), ], handler=add_handler, tags=["math", "basic"], )) async def add_handler(a: float, b: float) -> ToolResult: # 说明:这里故意不 try-except,让参数校验体系来兜底 val = a + b return ToolResult(tool_name="calculator.add", success=True, data={"sum": val})

这个插件包被加载后,AgentCore会在每一轮把calculator.add的 JSON Schema 附加到 LLM 请求中。模型看到这个工具的描述后,在自己觉得需要做加法时会发起调用。框架拦截到调用、通过校验、执行 handler、把结果拼回上下文。整个过程,框架核心代码零改动。

4.3 可回放日志记录器的实现

接下来重点看日志记录器,因为这是整个项目中最容易写“烂”的部分。我要求自己做到:零侵入、异步化、结构化。

# framework/log/recorder.py import json import queue import threading import time from typing import Any, Dict class AsyncLogRecorder: def __init__(self, file_path: str, batch_size: int = 100, flush_interval: float = 0.5): self._queue: queue.Queue = queue.Queue() self._batch_size = batch_size self._flush_interval = flush_interval self._buffer = [] self._lock = threading.Lock() self._stop_flag = False self._file = open(file_path, "a", encoding="utf-8") self._thread = threading.Thread(target=self._daemon, daemon=True) self._thread.start() def record(self, session_id: str, seq: int, event_type: str, payload: Dict[str, Any]): line = { "session_id": session_id, "seq": seq, "type": event_type, "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S.%fZ", time.gmtime()), "payload": payload, } self._queue.put(json.dumps(line, ensure_ascii=False)) def _daemon(self): while not self._stop_flag: try: item = self._queue.get(timeout=self._flush_interval) self._buffer.append(item) if len(self._buffer) >= self._batch_size: self._flush_locked() except queue.Empty: self._flush_locked() def _flush_locked(self): if not self._buffer: return with self._lock: if not self._buffer: return content = "\n".join(self._buffer) + "\n" self._file.write(content) self._file.flush() self._buffer.clear() def close(self): self._stop_flag = True self._thread.join(timeout=2) self._flush_locked() self._file.close()

这段代码我实际跑过很长时间,稳定性不错。有几个细节说明一下:

ensure_ascii=False必加。否则中文会被转成\uXXXX转义序列,日志文件完全不可读,而且回放时还要多一层解码。

线程用 daemon 模式。这样主进程退出时不会被日志线程阻塞,但会丢失极少量的尾部未写盘日志。对排查问题来说损失可接受,如果你的场景不能接受任何丢失,可以改用主进程退出前的atexit钩子强制 flush。

使用queue.Queue而不是threading.Condition或裸 list。Queue 自带了锁和阻塞语义,代码更不容易出错。写这个模块时要克制自己“优化性能”的冲动——这个模块最大的问题是阻塞主流程,不是吞吐量。

4.4 回放引擎的落地

最后我把回放引擎的代码完整体现出来,因为它最能体现“会话日志可回放”的价值:

# framework/replay/engine.py import json from typing import Dict, List, Optional from framework.log.recorder import AsyncLogRecorder from framework.plugin.protocol import ToolResult class ReplayContext: def __init__(self, events): self._events = events self._cursor = 0 self._tool_result_cache: Dict[str, ToolResult] = {} self._divergence = 0 def next_event(self): if self._cursor >= len(self._events): return None ev = self._events[self._cursor] self._cursor += 1 return ev def cache_tool_result(self, tool_name: str, result: ToolResult): self._tool_result_cache[(tool_name, self._cursor)] = result class ReplayEngine: def __init__(self, registry): self._registry = registry def load_log(self, file_path: str, session_id: str = None): events = [] with open(file_path, "r", encoding="utf-8") as f: for line in f: ev = json.loads(line) if session_id is None or ev.get("session_id") == session_id: events.append(ev) events.sort(key=lambda e: e["seq"]) return events def replay(self, events, agent_core, max_steps: int = 20) -> List[str]: """ 回放核心思路:按顺序消费日志事件 - 遇到 llm_request 事件,记录当时的 messages 供 agent_core 参考 - 遇到 tool_call 事件,直接从缓存查历史结果,拦截真实调用 """ ctx = ReplayContext(events) observation_steps = [] while True: ev = ctx.next_event() if ev is None: break if ev["type"] == "tool_result": ctx.cache_tool_result(ev["payload"]["tool"], ToolResult(**ev["payload"]["result"])) elif ev["type"] == "llm_request": # 注意:严格回放模式下,messages 直接用历史 messages # 因为我们就是要复现“当时模型看到的输入” # 这里简化为打印对比信息 observation_steps.append(ev["payload"]["step"]) return observation_steps

严格意义上,完整回放引擎还应该实现BEFORE_TOOL_CALL的插件拦截。由于篇幅关系,我不在这里贴出所有代码。但思路已经很清晰:回放的本质是“数据的时光机”,让调试者可以安全地回到历史时刻,用当时的数据、当时的上下文、当时的工具结果,去试验不同的修复策略。

5. 常见问题与排查技巧实录

5.1 插件化常见问题速查表

为了让你少踩坑,我把项目开发过程中遇到的高频问题整理成了下面的表格:

问题现象根本原因解决方案与排查思路
插件加载时报“工具名冲突”不同插件包定义了相同name的 ToolSpec检查命名空间前缀,遵守plugin_name.tool_name规范;必要时加版本号参与命名
模型一直不调用某个插件插件description写得不够具体,或者参数 Schema 太严格重写面向模型的描述;多给一点触发场景关键词;放宽非关键参数的required
工具执行时报参数类型错误模型的 function calling 经常传字符串而非数字在ToolSpec.validate_args中直接转换;不要期待模型永远遵循 Schema
回放日志和线上行为不一致LLM 有随机性,或外部状态变化区分“严格回放”和“自由回放”;排查时优先用严格回放对齐事实
异步日志队列堆积,服务内存飙升日志写入线程被 IO 阻塞,队列积压过多事件提高batch_size;改成双缓冲或背压策略;极端情况下可降级丢弃非关键日志
卸载插件后旧会话回放失败回放组件还引用着已卸载的工具名卸载前做引用检查;回放时若工具缺失,在日志中标记 MISSING_TOOL 并跳过

5.2 三个真金白银的排查技巧

技巧一:给 LLM 请求的 messages 做“指纹”哈希。

这个技巧帮我在排查“模型为什么同一个问题两次答得不一样”时省了无数时间。具体做法是对每轮发给 LLM 的messages计算一个 hash 值,存进日志。排查时只需要对比两次会话的 hash 就知道上下文到底哪里不一样。如果 hash 一样但输出不一样,那就是模型随机性导致的问题,可以考虑调低 temperature;如果 hash 不一样,再去对比哪个步骤的分叉导致了差异。

技巧二:日志中永远记录“工具描述是哪个版本”。

插件更新导致行为变化,是线上事故最常见原因之一。我在 ToolSpec 里加了一个version字段,日志记录时会把每次工具调用对应的插件版本写进去。之后看历史日志时,只要发现同一个工具在两个时间段里版本号不同,首先就要怀疑“是不是插件升级导致的回归”。有几次这个字段直接帮我找到了元凶,再也不用靠猜测。

技巧三:提前给日志系统写好一套“回放专用客户端命令”。

我这里不是指 CLI 脚本,而是指你必须在框架里预留一个“仅调试用”的命令行入口,比如python -m deepseek_harness.replay_cli --session-id xxx --step 3。为什么?因为在生产环境出问题时,你的第一要务是快速拉日志、跑回放,而不是重新写调试脚本。我早期吃过教训——日志是记了,但每次都要写一次临时脚本去解析,效率很低。后来把回放引擎封装成标准 CLI,排查效率直接翻倍。

5.3 关于日志文件轮转的提醒

如果你的 Agent 服务长期运行,日志文件会变得越来越大。我建议在AsyncLogRecorder上增加一个简单的按大小轮转逻辑,比如:

MAX_FILE_SIZE = 200 * 1024 * 1024 # 200MB 一个文件 if self._file.tell() > MAX_FILE_SIZE: self._file.close() self._file = open(f"{self._file_path}.{int(time.time())}", "a", encoding="utf-8")

不轮转的后果很直接——磁盘满了,日志写不进去,随后 Agent 主流程会因为日志队列阻塞而“假死”。这个坑我踩过一次之后,就再也不敢省略轮转了。

6. 项目扩展方向与个人体会

整个项目做到这里,回头看已经形成了一套比较完整的方法论:插件协议把变化封装进边界,事件总线把横切关注点从业务工具中剥离,异步日志把真相冻结在时间点,回放引擎把冻结的真相转化为调试能力。这四个环节彼此咬合,才构成了一个真正能上生产的 Agent 运行框架。

后续我打算继续扩展的方向有三个,给感兴趣的读者一点参考:

第一个方向,完善插件治理体系。目前插件加载是“手动调用load_plugin”,距离真正的“外部插件市场”还有距离。下一步我想实现一个配置文件驱动的插件列表,支持从私有仓库拉取插件包、校验插件签名、灰度发布插件版本。毕竟插件多了之后,安全性和版本管理一定会成为问题。

第二个方向,把回放引擎和自动化测试结合起来。现在回放主要用于“事后排查”。理想状态下,我想在每次代码变更后自动抽取线上最近一段时间的高价值会话日志,批量跑一遍回放,用日志中的工具结果作为 mock,然后用断言检查 Agent 是否在关键步骤仍然做出了正确决策。这就等于给 Agent 上了一层“回归测试保险”。

第三个方向,探索会话级状态降级与恢复。某些长会话中,上下文窗口会逐渐填满,甚至出现 Token 超限。借助快照日志,我可以做到在接近窗口上限时,把历史关键信息做摘要、压缩,然后继续运行。回放日志实际上已经存储了完整的上下文状态数据,只是目前还没有被用于“记忆管理”。

最后分享一点个人心得。Agent 框架的开发,容易让人沉浸在新功能的快感里,而忽略了工程底线。但真正让你在赛场上活下来的,往往是那些看上去“没那么酷”的设计:统一的工具协议、严格的日志结构、可复现的调试路径。我在这个项目里最大的收获,不是写出了多复杂的代码,而是建立了一条从线上故障到代码修复的“最短链路”——不止是能发现问题,更重要的是能稳定地复现问题、验证修复。做 Agent 工程化,建议大家也把这条链路当作第一优先级的基建。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询