![]() 玄同 765 AI Engineer | ▍深耕领域 AI Agent 架构设计 Agent Harness 工程 Context Engineering 多智能体协同 & RAG |
| 「 让 AI 交互更智能,让技术落地更高效 」 | 欢迎技术探讨 / 项目合作,关注解锁大模型与智能交互的无限可能! | |
【Agent Harness 架构手册 #05】AI Agent 自进化“经验蒸馏飞轮(Knowledge Flywheel)”与 SSE 断点续传(Replayable Stream)架构实战
📚 《Agent Harness 架构手册:构建高可靠 AI 智能体运行时》五篇连载导航
- #01 配额与自愈:LangGraph 动态工具配额守卫实战:根治无限探索与 return_direct 报错不自愈
- #02 可观测交互:AI Agent 实时动态流(Action Feed)与阶段性结论合成引擎实战
- #03 上下文投影:多轮对话上下文引擎实战:L1~L4 四级预算投影与无损回放
- #04 零信任安全:企业级 Agent 零信任权限审计与 AST 行级安全注入:防止长对话中途撤权泄密
- #05 自进化与重连:AI Agent 自进化经验蒸馏飞轮与 SSE 游标断点重连实战
欢迎来到本专栏的第 5 篇收官之作!在前四篇中,我们依次构建了 动态工具配额守卫(#01)、声明式 Action Feed 实时动态流(#02)、L1~L4 四级历史投影引擎(#03) 以及 三重零信任权限审计链与 AST 行级安全注入(#04)。
当你的 Agent 系统平稳运行起来后,要想从“能用的工具”蜕变为“越用越聪明的企业级智能体平台”,还必须攻克最后两个硬骨头:
- Agent 不会“吃一堑长一智”,每次都在同一个业务口径上摔跤:
用户在对话里花了 5 轮才教会 Agent:“算华东区净毛利时必须剔除内部调拨单”。结果换了一个新会话,Agent 又忘了这回事!传统做法是让研发人员手动去改 Prompt 或写死知识库文档,不仅响应慢,还无法形成闭环。如何让 Agent 从用户的 点赞(Positive)、点踩纠错(Negative) 甚至 高频追问日志(Active) 中,自动提炼出可复用的Skill(技能)、Knowledge(知识)或View Proposal(数据视图修订提案)? - 浏览器一刷新或切后台,跑了一半的 SSE 流式任务就彻底断联:
传统的 FastAPIStreamingResponse将 Agent 的推理协程与 HTTP 请求生命周期死死绑在一起。一旦用户在 Agent 执行到第 8 步时不小心按了F5刷新页面,或者手机切了个 Wi-Fi,前端重连回来时流已经断了;更糟糕的是,当用户看 Agent 查错了方向,想在中途喊一句“别查去年了,改查今年 Q3!”时,只能硬生生点停止重来。
本文将带你完整实现 「三模自进化经验蒸馏飞轮(Sediment Policy Engine)」 与 「基于内存游标缓冲池的 SSE 断点续传 + 运行期动态控向(Replayable Stream & In-Flight Steer)」,为整套 Agent Harness 架构画上圆满句号!
文章目录
- 【Agent Harness 架构手册 #05】AI Agent 自进化“经验蒸馏飞轮(Knowledge Flywheel)”与 SSE 断点续传(Replayable Stream)架构实战
一、为什么“静态知识库 + 绑定 HTTP 连接的流式推理”走不远?(原理拆解篇)
1.1 从“名医坐诊的医案整理本”与“高铁直播的云端录像回放”说起
为什么大多数团队做出来的 Agent 上线三个月后准确率和第一天一模一样,而且极其害怕网络抖动?
我们可以用两个非常直观的生活场景来拆解背后的架构缺陷:
- 为什么 Agent 不会自我进化?(缺少受控的“医案沉淀飞轮”):
老中医每天看完病,到了晚上都会把白天 “一剂见效的疑难杂症(正向反馈 Positive)” 整理成标准方剂(register_sop_playbook/publish_doc_entry),把 “初诊开错药、复诊才修正过来的病例(负向反馈 Negative)” 记入错题本并修订诊疗指南(submit_schema_patch)。
但在做自动化沉淀 Agent 时,如果不加工具白名单与单次交付门禁,沉淀 Agent 很容易失控——比如本来只是让它从点赞对话里总结一篇知识文档,它却自己连上生产库乱跑试探 SQL;或者一口气连建 5 个重复的碎片技能!因此,我们需要一套 按沉淀模式(positive / negative / active)严格收敛工具白名单与单次 Terminal 门禁的ExperienceDistillMiddleware。 - 为什么刷新页面会丢流?(把“演播室摄像机”直接焊死在了“观众的电视机插头”上):
传统 SSE 把 Agent 的async for event in agent.run()直接写在 FastAPI 的StreamingResponse生成器函数内部。这就好比观众家里一拔电视机插头(浏览器刷新断开 TCP),演播室的摄像机也当场断电停机!
正确的 Replayable Stream(断点续传流) 架构是:将 Agent 运行在独立的后台异步任务(Detached Task)中,所有产出的事件按递增序号seq = 0, 1, 2...写入内存环形缓冲池(ReplayableEventBus)并同步落盘。无论前端断线重连多少次,只需带上?after=last_seq,就能瞬间补齐错过的事件并无缝接上实时流!
下面的全局架构图展示了 “自进化沉淀飞轮” 与 “可断点续传/可中途控向的流式运行时” 如何协同工作:
1.2 三种经验蒸馏模式(Positive / Negative / Active)权限与策略全景对比
为了让自进化沉淀 Agent 既能精准提炼资产,又绝对不会越权乱改,我们将其划分为三种严格的运行模式:
沉淀模式 (DistillTriggerMode) | 触发时机与输入证据 | 允许调用的探索工具 (Discovery Whitelist) | 允许调用的唯一交付工具 (Terminal Tools) | 无数据库连接器 (has_connector=False) 时的降级策略 |
|---|---|---|---|---|
positive(正向经验提炼) | 用户对某轮回答点赞,或一次多步分析成功解决复杂问题 | 仅限轻量检索:search_tech_docs, fetch_doc_section, list_doc_index, search_data_catalog, load_sop_playbook | register_sop_playbook(主路径)、publish_doc_entry(辅助) | 完全不受影响(正向沉淀无需连接源库跑 SQL) |
negative(负向错题修正) | 用户点踩、人工纠错,或对话中模型先查错后经用户指正才查对 | 在 positive 基础上额外开放:inspect_table_schema, list_raw_tables, inspect_raw_tables, verify_sql_syntax, run_diagnostic_sql | submit_schema_patch、submit_schema_proposal、publish_doc_entry | 自动从白名单剔除沙箱探测工具与 submit_schema_proposal(因无法连库校验新 SQL),仅允许修订元数据或沉淀排障知识 |
active(主动日志巡检) | 定时任务批量扫描近期高频追问与未命中查询 | 同 negative 全量探索集 | 合并 positive 与 negative 全部交付工具 | 自动剔除需连库校验的沙箱与新视图提案工具 |
💡 核心设计准则:
“单一事实来源(Single Source of Truth)+ 单次交付即锁死(Single-Terminal Lock)”——沉淀 Agent 在挂载工具(Engine 层)和运行时拦截(Middleware 层)共享同一套 resolve_active_toolset(mode, has_connector) 函数;且每轮沉淀最多只允许成功调用 1 次交付工具,彻底杜绝一次后台任务胡乱生成一堆垃圾文档!
二、基础篇:3 分钟实现受控自进化沉淀中间件(ExperienceDistillMiddleware)
我们先把自进化飞轮的核心控制层写出来。它需要保证三件事:
- 严格白名单拦截:任何不在当前
DistillTriggerMode允许列表里的工具调用,直接拦截; - 探索步数封顶(
_MAX_DISCOVERY_STEPS = 14):在调用最终的交付工具前,最多允许查 14 次现有资产和知识库(防止重造轮子,但不能无限查下去); - 单次交付锁(
terminal_completed):一旦register_sop_playbook或submit_schema_patch成功执行 1 次,立即锁死本轮,禁止再顺手创建第二个产物。
2.1 完整可复用代码:ExperienceDistillMiddleware 策略引擎
from __future__ import annotations
import contextvars
from dataclasses import dataclass
from typing import Any, Literal
from langchain_core.messages import ToolMessage
DistillTriggerMode = Literal["positive", "negative", "active"]
_DISCOVERY_POSITIVE = frozenset({
"search_tech_docs",
"fetch_doc_section",
"list_doc_index",
"search_data_catalog",
"load_sop_playbook",
})
_DISCOVERY_NEGATIVE_EXTRA = frozenset({
"inspect_table_schema",
"list_raw_tables",
"inspect_raw_tables",
"verify_sql_syntax",
"run_diagnostic_sql",
})
_CONNECTOR_REQUIRED_TOOLS = frozenset({
"list_raw_tables",
"inspect_raw_tables",
"verify_sql_syntax",
"run_diagnostic_sql",
"submit_schema_proposal",
})
_TERMINAL_POSITIVE = frozenset({"register_sop_playbook", "publish_doc_entry"})
_TERMINAL_NEGATIVE = frozenset({"submit_schema_patch", "submit_schema_proposal", "publish_doc_entry"})
ALLOWED_BY_MODE: dict[DistillTriggerMode, frozenset[str]] = {
"positive": _DISCOVERY_POSITIVE | _TERMINAL_POSITIVE,
"negative": _DISCOVERY_POSITIVE | _DISCOVERY_NEGATIVE_EXTRA | _TERMINAL_NEGATIVE,
"active": _DISCOVERY_POSITIVE | _DISCOVERY_NEGATIVE_EXTRA | _TERMINAL_POSITIVE | _TERMINAL_NEGATIVE,
}
TERMINAL_BY_MODE: dict[DistillTriggerMode, frozenset[str]] = {
"positive": _TERMINAL_POSITIVE,
"negative": _TERMINAL_NEGATIVE,
"active": _TERMINAL_POSITIVE | _TERMINAL_NEGATIVE,
}
_MAX_DISCOVERY_STEPS = 14
DISTILL_STEP_CUSHION = 8
def compute_distill_graph_limit() -> int:
"""计算与沉淀探索预算严格对齐的 LangGraph recursion_limit。"""
return 2 * _MAX_DISCOVERY_STEPS + DISTILL_STEP_CUSHION
def resolve_active_toolset(mode: DistillTriggerMode, *, has_connector: bool) -> frozenset[str]:
"""根据沉淀模式与数据源连接状态,动态计算唯一合法的工具白名单。"""
allowed = ALLOWED_BY_MODE[mode]
if mode in {"negative", "active"} and not has_connector:
return frozenset(allowed - _CONNECTOR_REQUIRED_TOOLS)
return frozenset(allowed)
@dataclass
class _SedimentTurnState:
discovery_calls: int = 0
terminal_completed: bool = False
_sediment_state_ctx: contextvars.ContextVar[_SedimentTurnState | None] = contextvars.ContextVar(
"sediment_state_ctx", default=None
)
class ExperienceDistillMiddleware:
"""自进化经验蒸馏 Agent 专用策略护栏:白名单隔离 + 14步查重上限 + 单次交付锁。"""
def __init__(self, mode: DistillTriggerMode, *, has_connector: bool = True) -> None:
self.mode: DistillTriggerMode = mode
self.has_connector = has_connector
self.allowed = resolve_active_toolset(mode, has_connector=has_connector)
self.terminals = TERMINAL_BY_MODE[mode] & self.allowed
async def awrap_tool_call(self, request: Any, handler: Any) -> ToolMessage:
state = _sediment_state_ctx.get()
if state is None:
state = _SedimentTurnState()
_sediment_state_ctx.set(state)
op_name = request.tool_call["name"]
call_id = request.tool_call["id"]
# 1. 单次交付锁:一旦成功产出过 1 个沉淀提案/技能,禁止继续调用任何工具
if state.terminal_completed:
return ToolMessage(
content="Error: 本轮沉淀已成功提交交付产物(单次交付锁生效),请直接输出总结结束本轮。",
tool_call_id=call_id,
name=op_name,
)
# 2. 模式白名单校验
if op_name not in self.allowed:
return ToolMessage(
content=f"Error: 当前沉淀模式 ({self.mode}) 禁止调用工具 {op_name}。",
tool_call_id=call_id,
name=op_name,
)
# 3. 查重探索步数上限校验(放行最终的 Terminal 交付工具)
if op_name not in self.terminals:
if state.discovery_calls >= _MAX_DISCOVERY_STEPS:
return ToolMessage(
content=(
f"Error: 沉淀前期查重探索步数已达上限 ({_MAX_DISCOVERY_STEPS} 步)。"
f"请立即调用交付工具 ({', '.join(sorted(self.terminals))}) 提交沉淀产物,或直接结束说明无需沉淀。"
),
tool_call_id=call_id,
name=op_name,
)
state.discovery_calls += 1
result = await handler(request)
content = str(result.content or "") if isinstance(result, ToolMessage) else str(result or "")
# 4. 若交付工具成功执行(未返回 Error),立即锁定本轮
if op_name in self.terminals and not content.strip().startswith(("Error:", "[ERROR]")):
state.terminal_completed = True
return result
三、进阶篇:不怕刷新页面的 SSE 断点续传(ReplayableEventBus)与游标回放实战
解决了后台的自进化飞轮,我们再来攻克前端用户体验的头号顽疾:怎么让一个耗时 30 秒的 SSE 流式 Agent 任务,在用户任意刷新页面、切换标签页甚至断网重连时,100% 不中断后台执行,并且前端重连后能从断开的那一帧毫秒级续接?
3.1 全链路架构拆解:后台脱钩执行(Detached Task)+ asyncio.Condition 游标订阅
核心架构秘诀只有一句话:永远不要把 agent.run() 放在 HTTP 响应生成器里,而是用 asyncio.create_task() 启动独立的后台生产者往 ReplayableEventBus.events 列表里追加带序号 seq 的事件;HTTP SSE 接口只是一个拿着游标 cursor 来读取该列表的消费者!
3.2 完整可复用代码:支持多端同时订阅与断点续传的 ReplayableStreamHub
下面这段约 60 行的 Python 异步代码,仅用标准库 asyncio.Condition 就实现了一个工业级的 SSE 断点续传中心(支持任意多个客户端同时订阅同一个 run_id,支持 after_seq 历史补齐与实时流无缝切换):
import asyncio, json, time
from dataclasses import dataclass, field
from typing import Any, AsyncIterator
@dataclass
class ReplayableEventBus:
"""单次 Agent 运行的内存断点续传缓冲池。"""
exec_ticket_id: str
thread_key: str
user_id: str
created_at: float = field(default_factory=time.time)
events: list[dict[str, Any]] = field(default_factory=list)
done: bool = False
error: str | None = None
_cond: asyncio.Condition = field(default_factory=asyncio.Condition)
async def publish(self, event_type: str, data: dict[str, Any]) -> int:
"""由后台 Agent 生产者调用:追加新事件并唤醒所有订阅者。"""
async with self._cond:
seq = len(self.events) + 1
envelope = {"seq": seq, "type": event_type, "data": data, "ts": round(time.time(), 3)}
self.events.append(envelope)
self._cond.notify_all()
return seq
async def mark_finished(self, error: str | None = None) -> None:
"""标记本次运行已彻底结束(正常完成或异常终止),唤醒所有挂起的消费者退出循环。"""
async with self._cond:
self.error = error
self.done = True
self._cond.notify_all()
async def subscribe(self, after_seq: int = 0, heartbeat_sec: float = 15.0) -> AsyncIterator[str]:
"""
由 FastAPI StreamingResponse 调用:
1. 先瞬间回放所有 seq > after_seq 的已缓存事件;
2. 若运行尚未结束,则挂起等待新事件通知,期间定期发送 SSE 心跳防网关超时断开。
"""
cursor = max(0, int(after_seq))
while True:
async with self._cond:
while cursor >= len(self.events) and not self.done:
try:
await asyncio.wait_for(self._cond.wait(), timeout=heartbeat_sec)
except asyncio.TimeoutError:
# 发送标准 SSE 注释心跳帧,防止 Nginx / 负载均衡器 60s 切断空闲连接
yield ": heartbeat\n\n"
break
batch = self.events[cursor:]
cursor = len(self.events)
is_done = self.done
for item in batch:
payload = json.dumps(item, ensure_ascii=False)
yield f"id: {item['seq']}\nevent: {item['type']}\ndata: {payload}\n\n"
if is_done and cursor >= len(self.events):
break
四、高阶技巧:运行期动态控向(In-Flight Steer)与无 interrupt() 确定性提案确认
在流式运行时中,除了刷新重连,还有两个让架构师极易踩坑的高阶交互场景:
- 当 Agent 正在执行第 3 步工具时,用户看实时 Activity 流发现它查错了口径,想立刻发一条指令“别看含税价,改看未税价!”(In-Flight Steer),怎么在不强杀重开会话的前提下让 Agent 立刻拐弯?
- 当 Builder Agent 准备创建或修改一个生产数据视图时,必须先让管理员在界面上点击“确认采纳”才能写库。为什么我们强烈建议 不要用 LangGraph 的
interrupt()挂起图状态,而改用 “终止型提案工具(Terminal Proposal Tool)”?
4.1 为什么“终止型提案工具(Terminal Proposal)”远胜 interrupt() 挂起?
很多教程教大家用 LangGraph 的 interrupt() 实现人工确认(Human-in-the-Loop):当工具跑一半时把整个图的内存 Checkpoint 冻结存进数据库,等用户点确认按钮时再 resume 恢复图执行。
但在真实的 Web 生产环境里,interrupt() 有三个致命痛点:
- 如果用户打开了提案卡片,却没有点“确认”也没有点“拒绝”,而是直接输入了一句新问题:“算了,先把昨天那个指标查一下”,处于
interrupted悬挂状态的图 Checkpoint 就会直接报状态冲突! - 升级代码或调整 Prompt 后,旧版本冻结在数据库里的二进制 Checkpoint 反序列化经常报错。
而 「终止型提案工具(submit_schema_proposal + return_direct=True)」 的设计极其清爽:
- 当模型调用
submit_schema_proposal(name, sql, description)时,工具内部只做参数与 SQL 合法性校验,然后将完整提案 JSON 作为confirmation事件推给前端,并正常结束本轮 Agent 运行(图状态 100% 干净完结,没有任何悬挂锁!); - 当用户在前端点击「确认创建」按钮时,前端直接调用一个纯确定性的 REST 接口
POST /api/views/confirm-proposal落库即可,根本不需要再唤醒大模型花钱跑一轮!
4.2 运行期动态控向(In-Flight Steer)步边界注入流转图与代码实现
对于正在运行中的 Agent,我们为每个会话提供一个轻量的 SteerInbox(控向收件箱):
下面是 SteerManager 运行期控向管理器的完整可复用实现:
from collections import deque
from dataclasses import dataclass, field
from langchain_core.messages import HumanMessage
@dataclass
class SteerCommand:
"""用户在 Agent 运行中途下发的实时控向指令。"""
directive_kind: Literal["redirect", "stop"]
guidance_note: str = ""
submitted_at: float = field(default_factory=time.time)
class SteerManager:
"""按 thread_key 维护运行中 Agent 的实时控向收件箱。"""
def __init__(self) -> None:
self._inboxes: dict[str, deque[SteerCommand]] = {}
def submit_steer(self, thread_key: str, directive_kind: Literal["redirect", "stop"], guidance_note: str = "") -> None:
"""供 POST /qa/steer/{thread_key} 路由调用:入队一条实时控向指令。"""
box = self._inboxes.setdefault(thread_key, deque())
box.append(SteerCommand(action=action, instruction=instruction.strip()))
def drain_at_step_boundary(self, thread_key: str) -> tuple[bool, list[HumanMessage]]:
"""
在每个工具步执行完毕、即将进入下一轮 model 推理前的步边界(Step Boundary)调用:
返回 (should_stop_immediately, injected_human_messages)
"""
box = self._inboxes.get(thread_key)
if not box:
return False, []
injected: list[HumanMessage] = []
should_stop = False
while box:
cmd = box.popleft()
if cmd.directive_kind == "stop":
should_stop = True
elif cmd.instruction:
injected.append(
HumanMessage(content=f"[用户运行中实时补充指令] 请立即按以下最新要求调整后续操作:{cmd.instruction}")
)
return should_stop, injected
五、实战避坑指南:高频报错与一键解决速查表
在落地自进化沉淀飞轮与 SSE 断点续传架构时,如果遇到以下典型问题,可直接对照下表快速排障:
| 序号 | 遇到的报错或现象 | 背后的真正原因 | 一句话解决办法 |
|---|---|---|---|
| 01 | 用户在 Agent 跑工具时点了“停止/纠偏”,结果下一轮对话报 400: An assistant message with tool_calls must be followed by tool messages | 在工具还没返回 ToolMessage 时就直接用 task.cancel() 硬劈了协程,导致半截 tool_calls 落库 | 严禁在工具执行半空中强插消息或裸存半截状态!统一在当前工具步结束后的 步边界(Step Boundary) 消费 SteerManager 指令 |
| 02 | 浏览器使用原生 EventSource 重连时,服务端没有收到上次断开的位置,又从第 1 条事件重复推了一遍 | 既未解析请求参数 ?after=seq,也未读取浏览器 EventSource 自动附带的 Last-Event-ID 请求头 | 在 SSE 帧中统一输出 id: {seq}\n,并在服务端入口用 after_seq = query_after or int(request.headers.get("Last-Event-ID", 0)) 双重兼容 |
| 03 | 后台沉淀任务(Sediment Runner)在高并发下把在线问答的数据库连接池占满了 | 离线/异步沉淀任务与在线实时问答共用了同一个数据库连接池且无并发上限 | 为后台沉淀 Worker 配置独立的低优先级信号量(如 asyncio.Semaphore(2)),并在无源库连接时通过 resolve_active_toolset(has_connector=False) 自动剥离连库工具 |
| 04 | ReplayableEventBus 跑完后一直留在内存字典里,运行三天后 FastAPI 进程内存暴涨(OOM) | 缺少已完成运行缓冲池的 TTL 淘汰机制 | 在 mark_finished 时将完整事件归档落盘至数据库,并为内存中的 ReplayableEventBus 设置 300 秒 TTL 惰性清理任务 |
| 05 | Nginx 反向代理下,当某个大数据分析工具耗时超过 60 秒无新事件产出时,前端 SSE 流被网关强行掐断报 504 | 连接空闲时间超过了反向代理默认的 proxy_read_timeout(60s) | 在 ReplayableEventBus.subscribe 中将 wait_for 超时设为 15.0 秒,超时即刻输出一行标准 SSE 注释心跳 : heartbeat\n\n 保活连接 |
结语:五大核心支柱,铸就生产级 Agent Harness 架构闭环
至此,我们的 《Agent Harness 架构手册:构建高可靠 AI 智能体运行时》 五篇连载就全部圆满收官了!让我们用一张全景导图回顾这五大工程支柱如何环环相扣:
- 【#01 动态工具配额守卫(Dynamic Tool Quota Guard)】:用角色配额、沙箱连击锁、验证后单向锁与
Command(goto="model")结束型工具报错自愈,管住 Agent 的“手脚”; - 【#02 声明式实时活动流(Action Feed Engine)】:用
tool.metadata声明式挂载与Running/Done双态确定性语义解析器,让 Agent 的“一举一动”清晰可读、零前端硬编码; - 【#03 多轮历史四级预算投影(Context Engine v2)】:用不可变原始存储 +
L1 预览 -> L2 结果卡片 -> L3 台账 -> L4 折叠四级动态视图,管住 Agent 的“长期记忆与追问精度”; - 【#04 零信任权限审计链与 AST 行级安全注入(Fail-Closed Security)】:用三重实时权限核验、跨代次(Grant Epoch)旧数据遮罩与
sqlglotAST 子查询重写,管住 Agent 的“安全底线”; - 【#05 自进化沉淀飞轮与 SSE 断点续传(Flywheel & Replayable Stream)】:用受控的正负向经验蒸馏闭环、游标级断点续传与步边界实时控向,赋予 Agent “持续进化的生命力与丝滑交互体验”!
希望这套从真实大规模生产实践中提炼出的 Agent Harness 架构手册,能成为你打造下一代企业级 AI 智能体系统最趁手的工程蓝图!欢迎关注本专栏,我们下个系列再见!





294



被折叠的 条评论
为什么被折叠?



