Coverage for merco/observability/observer.py: 89%

162 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-07 14:04 +0800

1"""Observer — 订阅 hooks 事件的轻量可观察性门面""" 

2 

3from merco.hooks.registry import HookRegistry 

4from merco.observability.metrics import MetricsCollector 

5 

6 

7class Observer: 

8 """Agent 可观察性门面。 

9 

10 两套计数器: 

11 - `_live`: 当前运行(/new / /sessions 切换时 reset) 

12 - `_acc_map`: 跨运行累计(持久化到 SQLite,从不重置) 

13 """ 

14 

15 def __init__(self, hooks: HookRegistry): 

16 self._live = MetricsCollector() 

17 self._acc_map: dict[str, int] = {} 

18 self._last_merged: dict[str, int] = {} # 记录上次合并时的 live 值 

19 self._current_session: str | None = None # 当前会话 ID 

20 

21 hooks.on("llm.chat", self._on_llm) 

22 hooks.on("tool.after_execute", self._on_tool) 

23 hooks.on("tool.error", self._on_error) 

24 hooks.on("conversation.turn", self._on_turn) 

25 hooks.on("agent.interrupted", self._on_interrupt) 

26 hooks.on("agent.start", self._on_agent_start) 

27 hooks.on("agent.stop", self._on_agent_stop) 

28 hooks.on("context.compact", self._on_context_compact) 

29 hooks.on("memory.saved", self._on_memory_saved) 

30 hooks.on("plugin.activated", self._on_plugin_activated) 

31 hooks.on("plugin.error", self._on_plugin_error) 

32 

33 # ── 事件处理 ────────────────────────────────────────── 

34 

35 def _on_llm(self, duration: float = 0, tokens_in: int = 0, 

36 tokens_out: int = 0, cached_tokens: int = 0, 

37 cache_read_tokens: int = 0, **kwargs): 

38 self._live.increment("llm_calls") 

39 self._live.record_timing("llm", duration) 

40 if tokens_in: 

41 self._live.increment("tokens_in", tokens_in) 

42 if tokens_out: 

43 self._live.increment("tokens_out", tokens_out) 

44 cache = cached_tokens or cache_read_tokens 

45 if cache: 

46 self._live.increment("cache_hit_tokens", cache) 

47 

48 def _on_tool(self, tool_name: str = "", duration: float = 0, **kwargs): 

49 self._live.increment("tool_calls") 

50 self._live.increment(f"tool.{tool_name}") 

51 self._live.record_timing(f"tool.{tool_name}", duration) 

52 

53 def _on_error(self, tool_name: str = "", error: str = "", **kwargs): 

54 self._live.increment("errors") 

55 

56 def _on_turn(self, **kwargs): 

57 self._live.increment("turns") 

58 

59 def _on_interrupt(self, interrupted_tools: int = 0, **kwargs): 

60 """中断时记录统计。""" 

61 # 记录中断的 LLM 调用 

62 self._live.increment("llm_calls_interrupted") 

63 if interrupted_tools: 

64 self._live.increment("tool_calls_interrupted", interrupted_tools) 

65 self._live.increment("tool_calls", interrupted_tools) 

66 # 中断也算一轮(用户确实发起了请求) 

67 self._live.increment("turns") 

68 

69 def _on_agent_start(self, session_id: str, **kwargs): 

70 """Agent 启动""" 

71 self._live.increment("agent_starts") 

72 self._current_session = session_id 

73 

74 def _on_agent_stop(self, session_id: str, **kwargs): 

75 """Agent 停止""" 

76 self._live.increment("agent_stops") 

77 

78 def _on_context_compact(self, strategy: str, **kwargs): 

79 """上下文压缩""" 

80 self._live.increment("context_compactions") 

81 

82 def _on_memory_saved(self, key: str = "", source: str = "", **kwargs): 

83 """记忆保存""" 

84 self._live.increment("memories_saved") 

85 

86 def _on_plugin_activated(self, plugin_name: str = "", **kwargs): 

87 """插件激活""" 

88 self._live.increment(f"plugin.{plugin_name}.activations") 

89 

90 def _on_plugin_error(self, plugin_name: str = "", **kwargs): 

91 """插件错误""" 

92 self._live.increment(f"plugin.{plugin_name}.errors") 

93 

94 # ── 生命周期 ────────────────────────────────────────── 

95 

96 def reset(self, full: bool = False): 

97 """清空 live 计数器。full=True 时也清空累计。""" 

98 self._live = MetricsCollector() 

99 self._last_merged = {} # 清空上次合并的值 

100 if full: 

101 self._acc_map = {} 

102 

103 # ── 持久化 ──────────────────────────────────────────── 

104 

105 def save(self): 

106 """存盘前:把 live 合并到 acc_map""" 

107 self._merge_to_acc() 

108 

109 def snapshot(self) -> dict: 

110 """导出:live 计数器 + acc_map 累计""" 

111 return { 

112 "live": self._live.get_counters(), 

113 "acc": dict(self._acc_map), 

114 } 

115 

116 def _merge_to_acc(self): 

117 # 只累加增量(当前值 - 上次合并的值) 

118 for k, v in self._live.get_counters().items(): 

119 last = self._last_merged.get(k, 0) 

120 delta = v - last 

121 if delta > 0: 

122 self._acc_map[k] = self._acc_map.get(k, 0) + delta 

123 self._last_merged[k] = v 

124 

125 def restore(self, data: dict): 

126 """从快照恢复 acc_map""" 

127 self._acc_map = dict(data.get("acc", {})) 

128 

129 # ── 报告 ────────────────────────────────────────────── 

130 

131 def report(self) -> str: 

132 live = self._live 

133 acc = self._acc_map 

134 

135 turns = live.get_counter("turns") 

136 llm_calls = live.get_counter("llm_calls") 

137 tool_calls = live.get_counter("tool_calls") 

138 errors = live.get_counter("errors") 

139 llm_avg = live.get_avg_timing("llm") 

140 tokens_in = live.get_counter("tokens_in") 

141 tokens_out = live.get_counter("tokens_out") 

142 cache_hit = live.get_counter("cache_hit_tokens") 

143 agent_starts = live.get_counter("agent_starts") 

144 agent_stops = live.get_counter("agent_stops") 

145 context_compactions = live.get_counter("context_compactions") 

146 

147 acc_turns = acc.get("turns", 0) 

148 acc_llm = acc.get("llm_calls", 0) 

149 acc_tools = acc.get("tool_calls", 0) 

150 acc_errors = acc.get("errors", 0) 

151 acc_tokens_in = acc.get("tokens_in", 0) 

152 acc_tokens_out = acc.get("tokens_out", 0) 

153 acc_agent_starts = acc.get("agent_starts", 0) 

154 acc_agent_stops = acc.get("agent_stops", 0) 

155 acc_compactions = acc.get("context_compactions", 0) 

156 

157 lines = ["[bold]📊 会话报告[/bold]", ""] 

158 

159 # 本次 

160 lines.append(f" 本次: {turns}{llm_calls} 次 LLM 平均 {llm_avg:.1f}s") 

161 if tokens_in or tokens_out: 

162 ratio = f" {cache_hit / tokens_in * 100:.0f}% 缓存命中" if tokens_in and cache_hit else "" 

163 lines.append(f"{_fmt_n(tokens_in)} tokens 出 {_fmt_n(tokens_out)} tokens {ratio}") 

164 if tool_calls: 

165 parts = [] 

166 for name in sorted(live.get_counters()): 

167 if name.startswith("tool.") and name != "tool_calls": 

168 cnt = live.get_counters().get(name, 0) 

169 avg = live.get_avg_timing(name) 

170 parts.append(f"[dim]{name[5:]}[/dim] {cnt}次({avg:.1f}s)") 

171 lines.append(f" 工具: {', '.join(parts)}" if parts else f" 工具: {tool_calls}") 

172 

173 interrupted_tools = live.get_counter("tool_calls_interrupted") 

174 interrupted_llm = live.get_counter("llm_calls_interrupted") 

175 if interrupted_tools or interrupted_llm: 

176 parts = [] 

177 if interrupted_llm: 

178 parts.append(f"{interrupted_llm} 次 LLM") 

179 if interrupted_tools: 

180 parts.append(f"{interrupted_tools} 次工具调用") 

181 lines.append(f" [yellow]中断: {', '.join(parts)}[/yellow]") 

182 

183 if errors: 

184 lines.append(f" [red]错误: {errors}[/red]") 

185 

186 # Agent 启动/停止 & 上下文压缩 

187 agent_parts = [] 

188 if agent_starts: 

189 agent_parts.append(f"启动 {agent_starts}") 

190 if agent_stops: 

191 agent_parts.append(f"停止 {agent_stops}") 

192 if context_compactions: 

193 agent_parts.append(f"上下文压缩 {context_compactions}") 

194 if agent_parts: 

195 lines.append(f" Agent: {', '.join(agent_parts)}") 

196 

197 # 累计 — acc 里已含上次 merge 的 live 值,只加「未合并增量」避免重复计数 

198 lm = self._last_merged 

199 u_turns = turns - lm.get("turns", 0) 

200 u_llm = llm_calls - lm.get("llm_calls", 0) 

201 u_tools = tool_calls - lm.get("tool_calls", 0) 

202 u_tokens_in = tokens_in - lm.get("tokens_in", 0) 

203 u_tokens_out = tokens_out - lm.get("tokens_out", 0) 

204 u_errors = errors - lm.get("errors", 0) 

205 u_agent_starts = agent_starts - lm.get("agent_starts", 0) 

206 u_agent_stops = agent_stops - lm.get("agent_stops", 0) 

207 u_compactions = context_compactions - lm.get("context_compactions", 0) 

208 

209 if acc_turns or acc_llm or acc_tools: 

210 lines.append("") 

211 lines.append( 

212 f" 累计: {acc_turns + u_turns}{acc_llm + u_llm} 次 LLM " 

213 f"{_fmt_n(acc_tokens_in + u_tokens_in)} tokens " 

214 f"{_fmt_n(acc_tokens_out + u_tokens_out)} tokens " 

215 f"{acc_tools + u_tools} 次工具" 

216 + (f" [red]{acc_errors + u_errors} 错误[/red]" if acc_errors + u_errors else "") 

217 ) 

218 

219 # 累计 Agent 启动/停止 & 上下文压缩 

220 acc_agent_parts = [] 

221 total_starts = acc_agent_starts + u_agent_starts 

222 total_stops = acc_agent_stops + u_agent_stops 

223 total_compactions = acc_compactions + u_compactions 

224 if total_starts: 

225 acc_agent_parts.append(f"启动 {total_starts}") 

226 if total_stops: 

227 acc_agent_parts.append(f"停止 {total_stops}") 

228 if total_compactions: 

229 acc_agent_parts.append(f"上下文压缩 {total_compactions}") 

230 if acc_agent_parts: 

231 lines.append(f" 累计: Agent {', '.join(acc_agent_parts)}") 

232 

233 if not turns and not acc_turns: 

234 lines.append(" [dim]暂无数据[/dim]") 

235 

236 return "\n".join(lines) 

237 

238 

239def _fmt_n(n: int) -> str: 

240 if n < 1000: 

241 return str(n) 

242 return f"{n / 1024:.1f}K"