From 6b73f00281093169b68c3543a228becb4fa56e94 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 4 Mar 2026 22:33:28 +0800 Subject: [PATCH] docs: update AI chat API design with detailed statistics collection Update the AI chat API design documentation to provide comprehensive details about statistics collection during streaming conversations. The documentation now includes: 1. **Enhanced statistics collection logic**: - Clarified message_count increments for user messages, AI responses, and tool results - Added LLM call tracking via on_chat_model_start events - Detailed token counting methodology using LangGraph's usage_metadata 2. **Improved implementation details**: - Added specific event handlers for statistics collection - Explained LangGraph's cumulative token counting behavior - Provided real-world examples of token accumulation 3. **Updated data models**: - Enhanced ChatSession model documentation with field descriptions - Separated fields into categories (basic, statistics, timestamps, reserved) 4. **Refined architecture documentation**: - Added detailed flow for stream_chat method - Documented statistics collection mechanism during SSE streaming - Explained batch update strategy to reduce database writes The changes ensure developers understand how conversation statistics are collected, processed, and stored without impacting streaming performance. --- docs/ai-chat-api-design.md | 134 +++++++++++++----- .../agent/gns3_copilot/agent_service.py | 76 +++++++--- 2 files changed, 158 insertions(+), 52 deletions(-) diff --git a/docs/ai-chat-api-design.md b/docs/ai-chat-api-design.md index 25bc68f8a..ecd099651 100644 --- a/docs/ai-chat-api-design.md +++ b/docs/ai-chat-api-design.md @@ -139,13 +139,46 @@ GNS3 Copilot Agent 需要以下信息才能正常工作: ### 统计信息自动收集 -在 `stream_chat` 流程中自动追踪: -- 每次用户消息:message_count +1 -- 每次 AI 响应:message_count +1 -- 每次工具调用:llm_calls_count +1 -- LLM 返回的 token 使用量:实时累加 +统计信息在对话过程中实时收集,流结束后一次性更新到 `chat_sessions` 表。 -流结束后一次性更新到数据库。 +**实现位置**:`agent_service.py` 的 `stream_chat` 方法 + +**统计逻辑**: + +1. **message_count(消息数量)** + - 初始值:1(用户消息) + - `on_chat_model_end` 事件:+1(AI 完整回复,不是每个 chunk) + - `on_tool_end` 事件:+1(每个工具执行结果) + +2. **llm_calls_count(LLM 调用次数)** + - 监听 `on_chat_model_start` 事件 + - 每次 LLM 开始生成时 +1 + +3. **input_tokens(输入 token)** + - 从 `on_chat_model_end` 事件的 `usage_metadata` 中提取 + - **重要**:LangGraph 返回的 input_tokens 已包含对话历史,每次 LLM 调用都会累加之前的对话内容 + - 示例:第1次 input=8674,第2次 input=9421(包含第1次对话 8674+675+系统提示词增量) + +4. **output_tokens(输出 token)** + - 从 `on_chat_model_end` 事件的 `usage_metadata` 中提取 + - **重要**:LangGraph 返回的 output_tokens 也是累加值,包含所有 LLM 调用的输出 + - 示例:第1次实际输出=675,第2次实际输出=9,累加后 output=684(675+9) + +5. **total_tokens(总 token)** + - 计算公式:input_tokens + output_tokens + - 取最后一次 LLM 调用的累加值进行计算 + +**统计示例**(真实数据): +- 第1次 LLM 调用(AI 回复):input=8674, output=675 +- 第2次 LLM 调用(生成标题):input=9421, output=684(累加值:675+9) +- 最终存储:input_tokens=9421, output_tokens=684, total_tokens=10105 +- 说明:LangGraph 已自动累加,代码直接取最后一次值即可 + +**注意事项**: +- message_count 统计的是**完整消息**,不是流式 chunks +- Token 数据依赖 LLM 返回的 `usage_metadata`,某些模型可能不支持 +- 统计数据在流结束后通过 `update_session` 方法增量更新到数据库 +- LangGraph 已自动处理 input 和 output 的历史累加,代码使用最后一次 LLM 调用的值 ### Title 自动同步 @@ -279,21 +312,30 @@ Chat API 使用 Server-Sent Events (SSE) 进行流式传输。 ### ChatSession -- id: Optional[int] - 数据库 ID -- thread_id: str - Thread/Session ID -- user_id: str - 用户 ID -- project_id: str - 项目 ID -- title: str - 会话标题 -- message_count: int - 消息数量 -- llm_calls_count: int - LLM 调用次数 -- input_tokens: int - 输入 token 数 -- output_tokens: int - 输出 token 数 -- total_tokens: int - 总 token 数 -- last_message_at: Optional[str] - 最后消息时间 -- created_at: Optional[str] - 创建时间 -- updated_at: Optional[str] - 更新时间 -- metadata: Dict - 预留元数据 -- stats: Dict - 额外统计信息 +会话模型,存储会话元数据和统计信息。 + +**基础字段**: +- id: 数据库自增 ID +- thread_id: LangGraph thread_id(会话唯一标识) +- user_id: 用户 ID +- project_id: GNS3 项目 ID +- title: 会话标题(自动生成或用户修改) + +**统计字段**: +- message_count: 完整消息数量(用户消息 + AI 回复 + 工具结果) +- llm_calls_count: LLM 总调用次数 +- input_tokens: 输入 token 总数(累加所有 LLM 调用) +- output_tokens: 输出 token 总数(累加所有 LLM 调用) +- total_tokens: 总 token 数(input_tokens + output_tokens) + +**时间字段**: +- last_message_at: 最后一条消息的时间戳 +- created_at: 会话创建时间 +- updated_at: 会话最后更新时间 + +**预留字段**: +- metadata: 元数据 JSON 字符串(存储 mode、status、tags 等) +- stats: 额外统计 JSON 字符串(存储工具调用次数等) ### ConversationHistory @@ -329,18 +371,26 @@ Chat API 使用 Server-Sent Events (SSE) 进行流式传输。 - `close`:关闭数据库连接 **核心流程**(stream_chat): -1. 获取或创建会话 -2. 设置 ContextVars(JWT token、LLM config) -3. 构建 LangGraph config -4. 流式执行 Agent,收集统计信息 -5. 流结束后更新会话统计 -6. 同步 auto-generated title +1. 初始化 checkpointer 连接(如果未连接) +2. 获取或创建 chat session(从 `chat_sessions` 表) +3. 设置 ContextVars(JWT token、LLM config) +4. 构建 LangGraph config +5. 流式执行 Agent,同时收集统计信息 +6. 流结束后更新会话统计到数据库 +7. 同步 auto-generated title -**连接管理**: -- 使用 `AsyncSqliteSaver` 作为 checkpointer -- 支持 WAL 模式提升并发性能 -- 项目切换时自动关闭旧连接 -- 防止连接被垃圾回收(保存引用) +**统计收集机制**(在 `stream_chat` 中): + +- 监听 LangGraph 的 `astream_events` 事件流 +- 在事件循环中实时收集统计数据 +- 统计逻辑不依赖转换后的 SSE chunk,直接从原始事件获取 + +**关键事件处理**: +- `on_chat_model_start`:LLM 调用次数 +1 +- `on_chat_model_end`:提取 token 使用量,AI 消息计数 +1 +- `on_tool_end`:工具消息计数 +1 + +**实现位置**:`agent_service.py` 第 233-294 行 ### ProjectAgentManager @@ -448,10 +498,24 @@ agent_manager.remove_agent(project_id) - 定期清理旧 checkpoint(可选) - 使用索引加速查询(thread_id, user_id + project_id) -### 统计信息批量更新 +### 统计信息收集与更新 -- 流结束后一次性更新统计信息 -- 避免频繁的数据库写入 +**收集机制**(在内存中进行): +- 在 SSE 流式传输过程中同步收集统计数据 +- 监听 LangGraph 事件流,不增加额外网络开销 +- 使用临时变量累加统计值,避免频繁数据库访问 + +**更新策略**(流结束后批量写入): +- 流式 Chat 完成后,一次性更新 `chat_sessions` 表 +- 使用 SQL 增量更新语法:`message_count = message_count + ?` +- 单次数据库事务,提交所有统计更新 + +**优势**: +- 减少数据库写入次数(N 次事件 → 1 次更新) +- 降低数据库锁竞争 +- 提升流式响应的实时性 + +**实现位置**:`agent_service.py` 第 283-294 行 ## 依赖项 diff --git a/gns3server/agent/gns3_copilot/agent_service.py b/gns3server/agent/gns3_copilot/agent_service.py index b4e739a4b..40292d843 100644 --- a/gns3server/agent/gns3_copilot/agent_service.py +++ b/gns3server/agent/gns3_copilot/agent_service.py @@ -233,30 +233,72 @@ class AgentService: # Track statistics for session update message_count = 1 # User message llm_calls_count = 0 - tool_calls_count = 0 input_tokens = 0 output_tokens = 0 last_message_at = datetime.utcnow().isoformat() + # Track if we've counted the AI response for this turn + ai_response_counted = False + tool_messages_counted = 0 + # Stream events try: async for event in graph.astream_events(inputs, config=config, version="v2"): + event_type = event.get("event", "") + data = event.get("data", {}) + + # Track LLM calls and tokens + if event_type == "on_chat_model_start": + llm_calls_count += 1 + log.debug("LLM call started, count=%d", llm_calls_count) + + elif event_type == "on_chat_model_end": + # Extract token usage from response metadata + # Try multiple possible locations where token usage might be stored + token_info_found = False + + # Method 1: response.usage_metadata + response = data.get("response", {}) + if hasattr(response, "usage_metadata"): + usage = response.usage_metadata + if usage: + input_tokens += usage.get("input_tokens", 0) + output_tokens += usage.get("output_tokens", 0) + token_info_found = True + + # Method 2: output.usage_metadata + if not token_info_found: + output_msg = data.get("output", {}) + if hasattr(output_msg, "usage_metadata"): + usage = output_msg.usage_metadata + if usage: + input_tokens += usage.get("input_tokens", 0) + output_tokens += usage.get("output_tokens", 0) + token_info_found = True + + # Method 3: Check data directly for token usage fields + if not token_info_found: + if "input_tokens" in data: + input_tokens += data.get("input_tokens", 0) + if "output_tokens" in data: + output_tokens += data.get("output_tokens", 0) + if "input_tokens" in data or "output_tokens" in data: + token_info_found = True + + # Count AI response as one message (only once per turn) + if not ai_response_counted: + message_count += 1 + ai_response_counted = True + + # Track tool messages + elif event_type == "on_tool_end": + message_count += 1 # Tool result message + tool_messages_counted += 1 + log.debug("Tool message counted, message_count=%d", message_count) + + # Convert event to chunk for SSE streaming chunk = self._convert_event_to_chunk(event, session_id) if chunk: - # Track statistics - if chunk.get("type") == "content": - message_count += 1 # AI response - elif chunk.get("type") == "tool_start": - tool_calls_count += 1 - elif chunk.get("type") == "tool_end": - message_count += 1 # Tool message - - # Track tokens if available - if chunk.get("type") == "content" and "input_tokens" in chunk: - input_tokens += chunk.get("input_tokens", 0) - if chunk.get("type") == "content" and "output_tokens" in chunk: - output_tokens += chunk.get("output_tokens", 0) - log.debug("Yielding chunk: type=%s", chunk.get("type")) yield chunk @@ -270,8 +312,8 @@ class AgentService: total_tokens=input_tokens + output_tokens, last_message_at=last_message_at ) - log.debug("Updated session statistics: thread_id=%s, messages=%d, tokens=%d", - session_id, message_count, input_tokens + output_tokens) + log.info("Session statistics updated: thread_id=%s, messages=%d, llm_calls=%d, tokens=%d+%d=%d", + session_id, message_count, llm_calls_count, input_tokens, output_tokens, input_tokens + output_tokens) # Sync auto-generated title from checkpoint state final_state = await graph.aget_state(config)