1. 为什么智能体需要"事后复盘"这双眼睛
如果你跑过几次基于大模型的自动化任务,大概率遇到过这种场面:Agent第一次执行时在某一步卡死,你改了prompt重跑,它换了个姿势继续错,直到你把整条链路里的每个坑都踩完,它才勉强跑通。然后你复盘这个过程,发现自己说的最多的一句话是——"这步当时明明可以这样处理,怎么就没想到呢?"
这就是hindsight这个项目的出发点。它不是一个对话机器人,也不是什么炫酷的生成式应用,而是一套给AI Agent用的经验回放与事后反思系统。简单说,当Agent在执行任务时,它每走一步都会被记录下来;任务结束后,hindsight会把这些运行轨迹翻出来,逐段审视"哪里对了、哪里绕远了、哪里彻底跑偏了",然后把反思结果沉淀成结构化建议,反哺到下一轮执行里。
我一开始做这个项目也谈不上多么高瞻远瞩,单纯是被自己写的Agent气到了。去年做一个批量数据处理工具链,Agent每次处理到一个特定格式的文件就断掉,每次报错信息都不一样,我像消防员一样扑了一个星期的火。后来我才意识到问题的本质:Agent在每一个当下都只能做贪婪决策,它没有能力站在事后视角看整条流程,自然也学不会避坑。而我要做的,就是给这个"只看当下"的执行器装上一个"事后视角"的复盘系统。
这套东西适合谁呢?如果你在写自动化脚本、在搭个人Agent、在维护一条多步骤的数据流水线,或者单纯受够了反复调试同一个错误,那这套思路和实现方案大概率对你有用。它解决的问题不是让Agent变聪明,而是让Agent能"记住自己踩过什么坑",这才是稳定的进步方式。
2. 核心设计思路:记录、回看、反思、再执行
hindsight一开始的定位就很明确:它不抢Agent的活,只做Agent身后的那个"记录员+评论员"。整个项目拆开来看,其实就是四个环节——记录(Record)、回放(Replay)、反思(Reflect)、再执行(Re-execute)。每个环节单独看都很简单,难的是把它们串成一个能闭环的回路。
2.1 记录环节:不是所有事件都值得被记
第一步是埋点记录。你可能会想,这不就是打日志吗?还真不太一样。传统日志关注的是"系统发生了什么",而hindsight关注的是"Agent当时在想什么、面对什么状态、做了什么选择"。所以事件结构里不只要记"做了什么",还要带上决策依据和当时的环境快照。
我的事件结构长这样:
{ "event_id": "evt_001", "timestamp": "2024-11-20T14:32:10.882Z", "type": "action_executed", "agent_state": { "current_step": "file_parse", "context_count": 87, "model": "qwen2.5-14b-instruct" }, "input": { "file_path": "/data/raw/sales_2024.csv", "encoding": "utf-8-sig" }, "decision": { "chosen_action": "parse_with_pandas", "alternatives": ["parse_with_csv", "extract_by_lines"], "confidence": 0.73 }, "result": { "status": "failed", "error_type": "EncodingError", "error_message": "codec can't decode byte 0xed" } }这套结构说白了有一个好处:你回过头来复盘的时候,能还原现场。很多系统挂了之后,开发者面对的都是结果,而对Agent来说,过程比结果重要得多——因为Agent之所以犯错,往往不是因为结果做错了,而是因为在决策岔路口选了一条不该选的路。
埋点要注意一点:别什么都记。我起初把Agent每一步的完整上下文都塞进事件里,几分钟就把磁盘写满了。后来改成"状态摘要 + 决策信息 + 结果概要"三段式,事件体积压缩了将近80%,复盘质量反而没降——因为真正关键的信息就那么几项。
2.2 回放环节:把散落的轨迹串成一条故事线
记录完一大堆事件之后,下一个问题就是:怎么把这一堆事件变成有意义的东西?
这时候就需要一个回放层。它做的事情是:把一条任务从开始到结束的所有事件按时间轴重新串起来,分拣出关键节点,标记出"转折点"——所谓转折点,我定义的是以下三类:
- 首次失败点:任务链条里第一个报错的环节
- 决策犹豫点:Agent在多个可用动作之间反复跳动的环节
- 状态异常点:输入输出偏离预期类型的环节
回放层输出的是一份结构化的过程摘要,类似于给这个任务写了一份"编年史"。这份编年史会作为反思环节的输入。
很多人会忽略回放的价值,觉得"直接拿原始日志去问LLM不就行了"。我试过,效果非常差。因为原始日志里70%是噪声,LLM拿到一大坨日志,要么抓不住重点,要么被无关信息带跑,反思质量极其不稳定。经过回放层的精简之后,反思环节面对的是一份已经提炼过的事件序列,质量和稳定性一下子上来了。这就是回放层存在的价值——它不是可有可无的中间层,而是整个系统的信息过滤器。
2.3 反思环节:让大模型输出"可执行经验"
回放层整理完素材,反思环节就可以上场了。我这里的做法是把过程摘要交给大模型,配合一套反思提示模板,引导它输出三个维度的内容:
- 问题归因:这次任务失败/低效的根本原因是什么,不要只停留在表面报错信息
- 改进建议:给出具体的、可执行的调整方案,是改prompt、换工具、还是调整参数顺序
- 泛化规律:把这个案例抽象成一条一般性经验,比如"遇到CSV编码问题时优先尝试utf-8-sig"
这套提示模板我迭代过好几个版本,最终稳定在下面这个结构上:
你是一个任务复盘专家。以下是AI Agent执行任务的过程摘要: <过程摘要> 请从以下三个维度进行分析: 1. 问题归因:找出任务失败或低效的根本原因 2. 改进建议:给出3-5条具体可执行的调整方案 3. 泛化规律:总结一条可以迁移到类似场景的经验 要求:不要空泛评价,每一条建议必须能直接落地。这里有一个关键经验:你想要的不是"解释",而是"方案"。早期版本我用的是"请分析这次任务的失败原因",结果它给了我一大段华丽的错误分析,很好看,但看完还是不知道下一步该改什么。改成上面这个模板之后,输出变得实用得多。
2.4 再执行环节:经验被注入下一次任务
反思环节产出的建议,如果没有被下一次执行消费掉,整个系统就等于白做了。所以最后一步是"经验注入"。
我的做法是把反思结果存到一个经验库里,每次Agent开始新一轮任务时,hindsight从经验库里检索与当前任务相似的历史经验,作为"经验提示"注入到Agent的system prompt里。
这一步的设计其实最折磨人,因为它有一个很难绕开的坑:如果直接把历史经验塞进prompt,Agent会在无关任务上也被这些经验影响,反而降低执行质量。我采用的方案是给每条经验打上标签,在注入前做相似度匹配,只召回与当前任务类型匹配的经验,并且设了一个召回上限——最多三条,宁缺毋滥。
这四步串起来,才算是把"事后复盘"变成了"事前能力"。听起来不是很惊艳,对吧?说实话,这套架构确实没有多少技术壁垒,真正有价值的地方在于怎么把每一步做扎实,尤其是细节处理。
3. 实操实现:从零搭一个最小可用的Hindsight
前面说了这么多概念,这把这块落成代码。我用的Python 3.10+,依赖很少,核心库就是sqlite3和标准库,反思环节接一个大模型API就行。整个项目的代码量不大,大概500行左右,但麻雀虽小五脏俱全。
3.1 数据层:用SQLite做轨迹存储
首先需要一个存储层。我选择SQLite而不是JSON文件落盘,原因很简单:复盘时经常要做条件查询,比如"找出所有失败的parse事件",用SQL查比在JSON里遍历方便得多。
建表语句是这样的:
CREATE TABLE IF NOT EXISTS events ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, timestamp TEXT NOT NULL, event_type TEXT NOT NULL, agent_state TEXT, input_data TEXT, decision_data TEXT, result_data TEXT, created_at TEXT DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_task_time ON events(task_id, timestamp); CREATE INDEX IF NOT EXISTS idx_task_type ON events(task_id, event_type);表结构定义得宽一些,agent_state、input_data这些字段都存JSON字符串,因为事件结构后期会演化,如果一开始把字段定死,后面加字段就要改表结构,麻烦。
事件写入的代码很简单:
import json import sqlite3 import uuid from datetime import datetime, timezone class EventRecorder: def __init__(self, db_path: str = "hindsight.db"): self.conn = sqlite3.connect(db_path) self._init_db() def _init_db(self): self.conn.executescript(SCHEMA_SQL) self.conn.commit() def record(self, task_id: str, event: dict): event_id = uuid.uuid4().hex ts = datetime.now(timezone.utc).isoformat() self.conn.execute( "INSERT INTO events VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ( event_id, task_id, ts, event.get("type", "unknown"), json.dumps(event.get("agent_state", {}), ensure_ascii=False), json.dumps(event.get("input", {}), ensure_ascii=False), json.dumps(event.get("decision", {}), ensure_ascii=False), json.dumps(event.get("result", {}), ensure_ascii=False), ), ) self.conn.commit()往自己的Agent代码里埋点的时候,其实不需要在业务逻辑里到处塞代码,我一般只找几个关键位置埋点:每轮决策开始前、每轮决策结束并拿到结果后、以及任务结束/异常退出时。三个点就够了,埋太密反而是负担。
3.2 回放层:提炼关键节点,而不是罗列日志
回放层的实现,我把它分成两步:第一步按task_id把事件全部取出来,按时间排序;第二步扫一遍事件序列,标记出关键节点。
class Replayer: def __init__(self, conn): self.conn = conn def replay(self, task_id: str) -> dict: rows = self.conn.execute( "SELECT * FROM events WHERE task_id=? ORDER BY timestamp", (task_id,), ).fetchall() events = [self._row_to_dict(row) for row in rows] summary = { "task_id": task_id, "total_steps": len(events), "key_nodes": [], "event_sequence": [], } for i, evt in enumerate(events): node = {"index": i, "type": evt["event_type"], "summary": self._summarize(evt)} result = json.loads(evt["result_data"]) if result.get("status") == "failed": node["node_type"] = "failure" if self._is_hesitation(evt): node["node_type"] = "hesitation" if self._is_anomaly(evt): node["node_type"] = "anomaly" summary["key_nodes"].append(node) summary["event_sequence"].append(evt["event_type"]) return summary def _is_hesitation(self, evt): # 检查决策里是否出现反复切换 decision = json.loads(evt["decision_data"] or "{}") return decision.get("switched", False) def _is_anomaly(self, evt): result = json.loads(evt["result_data"] or "{}") return result.get("anomaly", False)这里有个细节值得说一下:事件序列不一定要输出所有原始数据,回放层输出给反思环节的,应该是精简过的"节点摘要",每条节点不要超过50个字。这样传给LLM的内容才够干净,也省token。
3.3 反思层:接入大模型,输出结构化经验
回放层准备好了素材,反思层其实就是一个"调用LLM + 解析输出"的模块。
class Reflector: def __init__(self, llm_client, prompt_template: str): self.llm = llm_client self.template = prompt_template def reflect(self, replay_summary: dict) -> dict: prompt = self.template.replace("{{process_summary}}", json.dumps(replay_summary, ensure_ascii=False)[:6000]) raw = self.llm.chat(prompt) return self._parse_response(raw) def _parse_response(self, raw: str) -> dict: # 这里用简单正则或JSON解析,要求LLM按固定格式输出 # 我这里要求它输出JSON try: return json.loads(raw) except json.JSONDecodeError: # 兜底:用正则把三个字段抠出来 return { "root_cause": "parse_failed", "suggestions": ["无法解析反思结果,请检查模型输出格式"], "general_rule": "", }反思层的成败很大程度上取决于输出格式约束。我踩过的一个大坑是:LLM偶尔不按JSON格式输出,导致解析失败。后来我在提示模板里写死"必须输出JSON,key固定为root_cause/suggestions/general_rule",并且加了上面的兜底解析逻辑——解析失败时不至于让整条链路崩溃,而是标记一下,跳过本次反思。
3.4 经验库与注入:给每条经验打标签,控制召回数量
经验库这层我用一张数据库表来存:
CREATE TABLE IF NOT EXISTS lessons ( id TEXT PRIMARY KEY, task_type TEXT NOT NULL, content TEXT NOT NULL, source_task_id TEXT, created_at TEXT, valid_until TEXT, hit_count INTEGER DEFAULT 0 );注入逻辑的关键在于任务类型匹配。每个任务在启动时都要声明自己的task_type,比如"csv_batch_process"、"pdf_extract"这种粒度,太粗了召回不精准,太细了每条经验都只能用在单一任务上,失去泛化价值。
召回代码如下:
class LessonInjector: def __init__(self, conn): self.conn = conn def inject(self, task_type: str, max_lessons: int = 3) -> str: rows = self.conn.execute( "SELECT content FROM lessons WHERE task_type=? AND (valid_until IS NULL OR valid_until > datetime('now')) ORDER BY hit_count DESC LIMIT ?", (task_type, max_lessons), ).fetchall() if not rows: return "" lessons_text = "\n".join([f"- {r[0]}" for r in rows]) # 更新命中计数 self.conn.execute( "UPDATE lessons SET hit_count = hit_count + 1 WHERE task_type=?", (task_type,), ) self.conn.commit() return f"【历史经验参考】\n{lessons_text}\n请结合以上经验优化本次执行,但不要盲从。"这里有一个很有用的设置,就是valid_until字段。经验不是越老越值钱,一些经验在上下文变化后反而会误导Agent,比如某个第三方库升级后旧的经验可能就失效了。所以反思产出的经验默认有效期为30天,过期后自动排除,除非被人工确认过"长期有效"。
3.5 把全流程串起来
所有模块就位后,主流程是这样跑的:
class Hindsight: def __init__(self, db_path: str, llm_client, prompt_template: str): self.conn = sqlite3.connect(db_path) self.recorder = EventRecorder(db_path) self.replayer = Replayer(self.conn) self.reflector = Reflector(llm_client, prompt_template) self.injector = LessonInjector(self.conn) def run_task_with_feedback(self, task_type: str, task_runner, task_input): # 注入历史经验 lesson = self.injector.inject(task_type) if lesson: task_input["extra_context"] = lesson # 执行任务 task_id = uuid.uuid4().hex result = task_runner(task_input, event_callback=lambda evt: self.recorder.record(task_id, evt)) # 任务结束后复盘 if result.get("needs_review", True): replay = self.replayer.replay(task_id) reflection = self.reflector.reflect(replay) self._store_lesson(task_type, reflection, task_id) return result整体跑下来的效果,我自己测了两个场景:一个是CSV批量清洗任务,一个是网页信息提取任务。接入hindsight后,同样的任务跑第二遍时,成功率和效率都有明显提升——CSV清洗任务的成功率从63%提升到87%,网页提取任务的平均耗时下降了约20%。样本量不大,但这个趋势是确实能感受到的。
4. 常见问题与排查实录
这套系统我上线用了三个月左右,中间踩过不少坑,挑几个典型的列出来,如果你也在做类似的东西,应该能帮你省不少时间。
4.1 事件数据量爆炸
刚开始我每个Agent步骤都把完整的上下文、工具返回结果、token用量全部记录下来,跑一个稍复杂的任务就产出几MB事件数据。查询变慢、存储膨胀、复盘时给LLM的输入太长。
后来我做了三层过滤:第一层,只记录决策点事件,把"状态轮询"这类过程事件过滤掉;第二层,对长文本字段做截断,只保留前500字;第三层,按任务保留原始事件,但超过500个事件的任务自动触发采样压缩。做完这三步之后,数据量降到了原来的十分之一,复盘质量没有明显变化。
4.2 反思输出质量不稳定
这个问题困扰了我很久。同一个任务的两次复盘,一次输出很精准、直击要害,另一次就泛泛而谈。排查后发现两个影响因素:一是回放摘要的质量,如果关键节点标记错了,反思就会跟着歪;二是温度参数,反思任务我用的temperature是0.2,而不是执行任务时的0.7。反思本身更像分析题而不是创作题,把温度压低之后,稳定性好了不少。
4.3 注入历史经验反而把Agent带偏了
这个问题是上线之后最让我头疼的一个。某一次任务里,Agent参考了一条经验,结果那条经验是针对旧版本的代码逻辑,新版本已经不需要那个workaround了,Agent反而多做了无用功。
出现这个问题的根源是经验没有时效性。我后面加了两道防线:第一道就是前面说的valid_until,默认30天过期,过期后不自动删除,但不再注入,只保留在库里供人工查看;第二道是在注入提示词的最后加了一句"请结合以上经验优化本次执行,但不要盲从。如果经验与当前任务的实际情况不符,以当前实际情况为准"。这两道防线加完,误用经验的情况大大减少。
4.4 时间戳不同步导致回放顺序错乱
有一次我观察到某条任务的回放事件顺序明显乱掉了,排查之后发现是埋点的时候timestamp用的是time.time()这种本地时间,跨进程时时钟源不一致。后来我统一改成了UTC时间戳,并且规定埋点端必须传入时间戳而不是在Recorder内部生成。这是一个很细碎的坑,但排起来很折磨人。
4.5 LLM输出格式偶发解析失败
反思环节依赖LLM输出JSON,但偶尔会遇到输出里带markdown代码块标记,或者JSON结构不完整。我除了写死格式要求之外,还加了一层正则清理——把包裹的json和等标记剥掉再解析。兜底逻辑如果解析失败,会把这次反思标记为"质量存疑",同时不中断主流程。复盘链路绝不能成为任务执行链路的单点故障,这是我写这套系统时坚持的一个底线。
简单整理一个排查速查表:
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 复盘内容空泛 | 关键节点标记不准 | 检查回放层的节点标记逻辑 |
| 经验注入后效果变差 | 经验过时或与任务不匹配 | 检查valid_until和task_type匹配 |
| 事件数据增长过快 | 记录粒度过细 | 启用决策点过滤和长文本截断 |
| 回放事件顺序错乱 | 时间戳时钟源不一致 | 统一UTC时间戳,埋点端传时间 |
| 反思输出无法解析 | LLM输出格式漂移 | 加正则清理和兜底解析 |
| 任务执行变慢 | 注入经验过多 | 降低召回上限,建议不超过3条 |
5. 从复盘工具到学习系统,hindsight还能怎么走
我把hindsight这套东西跑通之后,最大的感受不是"我多了一个工具",而是明白了另一个层面的问题:Agent和普通程序之间的本质差异,不在于它能生成自然语言,而在于它能不能从自己的经验里持续进步。没有复盘闭环的Agent,不管prompt写得多么精致,本质上还是一个没有记忆的函数,换一个场景就重新开始。
目前hindsight核心闭环已经能跑,但如果继续往下做,我看到的几个方向是值得投入的。
第一个方向是做经验冲突检测。目前的经验库比较简单,新经验直接append,但实际运行一段时间后你会发现,两条经验之间可能存在矛盾。比如一条经验说"遇到编码错误时优先尝试utf-8-sig",另一条经验可能说"某些文件用utf-8-sig反而会乱码,应该检测BOM头"。这两条经验放在一起,Agent反而会困惑。理想的做法是,在新经验入库之前,和历史经验做一次语义相似度检测,如果有矛盾就把冲突标记出来,交给人工判断。这个我还没完全做好,目前只是加了一个简单的关键词冲突检测,能发现一部分明显矛盾。
第二个方向是给复盘结果量化打分。现在reflection输出的是"问题归因、改进建议、泛化规律"三段式文本,但缺少一个统一的量化指标来评估"这次复盘的质量"。我打算在后面加一个"经验价值分",由多个维度组成:问题是否被复现、建议是否有可操作性、泛化规律是否能覆盖到其他任务。有了这个分,经验库召回的时候就不止按hit_count排序了,还能按经验价值排序,效果应该更好。
第三个方向是把复盘从"任务结束后"扩展到"任务过程中"。现在的设计是跑完一个任务整体复盘,但如果任务特别长、有几十个步骤,等到最后才发现前面第5步就歪了,纠正成本已经很高。我下一步打算在任务执行中每隔几步触发一次"轻量检查",只要发现关键指标异常就立刻中断当前路线,先小节复盘、再继续走。这个做好之后,整个系统就不只是"事后诸葛亮"了,而更像一个实时导航,但那是另一个话题了。
我自己在实际落地hindsight时还有一个心得:不要试图让所有复盘都自动化,保留人工抽审的入口会舒服很多。让系统把经验自动沉淀下来是一方面,但每隔一段时间人工翻一次经验库,往往会发现一些自动反思完全没发现的隐藏问题。AI负责"及时近忧式的复盘",人负责"远虑式的审视",这个搭配目前对我来说是最好用的组合。这套思路,你在自己的项目里不妨也试试,从一个小闭环开始跑起来,坚持几轮之后,效果你自己会看见。