feat: 添加结构化日志系统,更新LLM配置与全部文档

新增:
- backend/logger.py — 集中日志模块 (JSON格式 + trace_id + 独立llm.log)
- @log_node / @_log_route 装饰器覆盖17个节点和8个路由

改进:
- backend/llm.py — _LLMLoggingWrapper 自动记录LLM输入输出
- backend/llm.py — API Key优先读ANTHROPIC_API_KEY,模型名改为MiniMax-M2.7
- backend/llm.py — get_llm() 新增caller参数标识调用来源
- backend/validation.py — 新增验证结果/连接失败日志
- backend/session.py — 新增会话创建/删除日志
- app.py — 新增用户交互日志 (输入/执行/异常/会话操作)
- app.py — 提前导入torchvision抑制transformers懒加载报错
- .env.example — 新增LOG_DIR/LOG_LEVEL/ANTHROPIC_API_KEY等配置项
- .gitignore — 新增logs/和db/忽略规则

文档:
- ROADMAP.md — 新增阶段四: 可观测性
- README.md — 补充日志架构/LLM配置/项目结构
- CLAUDE.md — 同步最新配置/日志/MAX_RETRY(3)
- CODE_GUIDE.md — 新增第15章日志系统,更新架构图/LLM/配置
This commit is contained in:
2026-05-19 23:40:01 +08:00
parent 6467fd4ae5
commit 067880bf2e
13 changed files with 753 additions and 82 deletions
+35
View File
@@ -1,5 +1,6 @@
"""LangGraph JRXML 生成代理的状态图定义。"""
import functools
import os
from typing import Literal
@@ -25,14 +26,41 @@ from agent.nodes import (
correct_jrxml,
finalize,
)
from backend.logger import get_logger
load_dotenv()
MAX_RETRY = int(os.getenv("MAX_RETRY", "3"))
_graph_log = get_logger("agent")
def _log_route(route_name: str):
"""装饰器:自动记录路由决策。"""
def decorator(func):
@functools.wraps(func)
def wrapper(state: AgentState, *args, **kwargs):
target = func(state, *args, **kwargs)
_graph_log.info(
f"[路由] {route_name}{target}",
extra={
"route": route_name,
"target": target,
"session_id": state.get("session_id", ""),
"intent": state.get("intent", ""),
"status": state.get("status", ""),
"has_jrxml": bool(state.get("current_jrxml", "").strip()),
"retry_count": state.get("retry_count", 0),
},
)
return target
return wrapper
return decorator
# ============================================================
# 路由函数
# ============================================================
@_log_route("route_by_intent")
def route_by_intent(state: AgentState) -> Literal[
"retrieve", "modify_jrxml", "save_session",
"handle_consult", "handle_undo", "handle_reset"
@@ -59,18 +87,22 @@ def route_by_intent(state: AgentState) -> Literal[
return "retrieve"
@_log_route("route_after_generate")
def route_after_generate(state: AgentState) -> Literal["save_session"]:
return "save_session"
@_log_route("route_after_modify")
def route_after_modify(state: AgentState) -> Literal["save_session"]:
return "save_session"
@_log_route("route_after_undo")
def route_after_undo(state: AgentState) -> Literal["save_session"]:
return "save_session"
@_log_route("route_after_save")
def route_after_save(state: AgentState) -> Literal["validate", "finalize"]:
# 预览/导出意图跳过验证,直接完成
intent = state.get("intent", "")
@@ -79,16 +111,19 @@ def route_after_save(state: AgentState) -> Literal["validate", "finalize"]:
return "validate"
@_log_route("route_after_validate")
def route_after_validate(state: AgentState) -> Literal["finalize", "explain_error"]:
if state.get("status") == "pass":
return "finalize"
return "explain_error"
@_log_route("route_after_explain")
def route_after_explain(state: AgentState) -> Literal["correct_jrxml"]:
return "correct_jrxml"
@_log_route("route_after_correct")
def route_after_correct(state: AgentState) -> Literal["validate", "finalize"]:
retry = state.get("retry_count", 0)
if retry >= MAX_RETRY:
+86 -7
View File
@@ -1,9 +1,11 @@
"""LangGraph JRXML 生成工作流的节点函数。"""
import copy
import functools
import json
import os
import re
import time
from datetime import datetime, timezone
from typing import Dict
@@ -11,21 +13,82 @@ from dotenv import load_dotenv
from agent.state import AgentState
from backend.llm import get_llm
from backend.logger import get_logger, set_trace_id
from backend.validation import validate_jrxml
from prompts.loader import load_prompt
load_dotenv()
_node_log = get_logger("agent")
MAX_RETRY = int(os.getenv("MAX_RETRY", "3"))
CONTEXT_MAX_TOKENS = int(os.getenv("CONTEXT_MAX_TOKENS", "6000"))
CONTEXT_KEEP_RECENT = int(os.getenv("CONTEXT_KEEP_RECENT", "4"))
HISTORY_MAX_SNAPSHOTS = int(os.getenv("HISTORY_MAX_SNAPSHOTS", "10"))
def _state_summary(state: AgentState) -> dict:
"""提取 state 中的关键字段用于日志摘要。"""
user_input = state.get("user_input", "")
return {
"session_id": state.get("session_id", ""),
"intent": state.get("intent", ""),
"status": state.get("status", ""),
"has_jrxml": bool(state.get("current_jrxml", "").strip()),
"jrxml_length": len(state.get("current_jrxml", "")),
"retry_count": state.get("retry_count", 0),
"user_input_preview": user_input[:100] if user_input else "",
"conversation_turns": len(state.get("conversation_history", [])),
"history_snapshots": len(state.get("history_states", [])),
"versions": len(state.get("jrxml_versions", [])),
}
def log_node(node_name: str):
"""装饰器:自动记录节点入口、出口和耗时。"""
def decorator(func):
@functools.wraps(func)
def wrapper(state: AgentState, *args, **kwargs):
t0 = time.time()
_node_log.info(
f"[节点入口] {node_name}",
extra={"node": node_name, "phase": "entry", "state": _state_summary(state)},
)
try:
result = func(state, *args, **kwargs)
elapsed_ms = round((time.time() - t0) * 1000)
_node_log.info(
f"[节点出口] {node_name}",
extra={
"node": node_name,
"phase": "exit",
"duration_ms": elapsed_ms,
"state": _state_summary(state),
},
)
return result
except Exception as e:
elapsed_ms = round((time.time() - t0) * 1000)
_node_log.error(
f"[节点异常] {node_name}: {e}",
extra={
"node": node_name,
"phase": "error",
"duration_ms": elapsed_ms,
"error": str(e),
"state": _state_summary(state),
},
)
raise
return wrapper
return decorator
# ============================================================
# 核心工作流节点
# ============================================================
@log_node("process_input")
def process_input(state: AgentState) -> Dict:
"""记录用户输入到对话历史,重置本轮请求状态。如有上次失败上下文则自动注入。"""
user_input = state.get("user_input", "")
@@ -58,6 +121,7 @@ def process_input(state: AgentState) -> Dict:
return state
@log_node("save_state_snapshot")
def save_state_snapshot(state: AgentState) -> Dict:
"""保存当前状态快照到 history_states,用于撤销操作。最多保留 N 个版本。"""
snapshots = state.get("history_states", [])
@@ -82,6 +146,7 @@ def save_state_snapshot(state: AgentState) -> Dict:
return state
@log_node("classify_intent")
def classify_intent(state: AgentState) -> Dict:
"""使用 LLM 对用户输入进行意图分类(8 种意图)。"""
user_input = state.get("user_input", "")
@@ -89,7 +154,7 @@ def classify_intent(state: AgentState) -> Dict:
intent = "initial_generation"
try:
llm = get_llm()
llm = get_llm(caller="classify_intent")
prompt = load_prompt("intent_classify").format(
has_report=has_report,
user_input=user_input[:500],
@@ -116,11 +181,12 @@ def classify_intent(state: AgentState) -> Dict:
return state
@log_node("handle_consult")
def handle_consult(state: AgentState) -> Dict:
"""处理咨询类问题:调用 LLM 直接回答,不走报表生成流程。"""
user_input = state.get("user_input", "")
try:
llm = get_llm()
llm = get_llm(caller="handle_consult")
prompt = load_prompt("consult").format(question=user_input)
resp = llm.invoke(prompt)
answer = resp.content.strip()
@@ -135,6 +201,7 @@ def handle_consult(state: AgentState) -> Dict:
return state
@log_node("handle_undo")
def handle_undo(state: AgentState) -> Dict:
"""撤销上一步修改:从 history_states 恢复最近一个快照。"""
snapshots = state.get("history_states", [])
@@ -162,6 +229,7 @@ def handle_undo(state: AgentState) -> Dict:
return state
@log_node("handle_reset")
def handle_reset(state: AgentState) -> Dict:
"""重置当前会话:清空报表相关状态,保留会话信息。"""
state["current_jrxml"] = ""
@@ -186,6 +254,7 @@ def handle_reset(state: AgentState) -> Dict:
return state
@log_node("count_tokens")
def count_tokens(state: AgentState) -> int:
"""使用 tiktoken(gpt-4o 编码器)计算当前上下文 token 数量。"""
try:
@@ -208,6 +277,7 @@ def count_tokens(state: AgentState) -> int:
return len(enc.encode(text))
@log_node("manage_context")
def manage_context(state: AgentState) -> Dict:
"""当 token 数量超过阈值时,压缩较早的对话轮次。"""
token_count = count_tokens(state)
@@ -230,7 +300,7 @@ def manage_context(state: AgentState) -> Dict:
conv_text = json.dumps(older, ensure_ascii=False, indent=2)
try:
llm = get_llm()
llm = get_llm(caller="manage_context")
prompt = load_prompt("compression").format(conversation_text=conv_text)
resp = llm.invoke(prompt)
new_compressed = resp.content.strip()[:300]
@@ -249,6 +319,7 @@ def manage_context(state: AgentState) -> Dict:
return state
@log_node("load_session_node")
def load_session_node(state: AgentState) -> Dict:
"""在请求开始时从磁盘加载会话状态。"""
session_id = state.get("session_id", "")
@@ -273,6 +344,7 @@ def load_session_node(state: AgentState) -> Dict:
return state
@log_node("save_session_node")
def save_session_node(state: AgentState) -> Dict:
"""将当前代理状态持久化到磁盘。"""
session_id = state.get("session_id", "")
@@ -319,6 +391,7 @@ def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
@log_node("retrieve")
def retrieve(state: AgentState) -> Dict:
"""在 ChromaDB + 错误知识库中搜索相关的 JRXML 模板和组件。"""
try:
@@ -341,12 +414,13 @@ def retrieve(state: AgentState) -> Dict:
return state
@log_node("generate")
def generate(state: AgentState) -> Dict:
"""根据用户需求和检索到的上下文生成初始 JRXML。"""
from langgraph.config import get_stream_writer
writer = get_stream_writer()
llm = get_llm()
llm = get_llm(caller="generate")
prompt = load_prompt("initial_generation").format(
context=state.get("retrieved_context", ""),
user_request=state.get("user_input", ""),
@@ -361,12 +435,13 @@ def generate(state: AgentState) -> Dict:
return state
@log_node("modify_jrxml")
def modify_jrxml(state: AgentState) -> Dict:
"""根据用户的修改请求修改现有 JRXML。"""
from langgraph.config import get_stream_writer
writer = get_stream_writer()
llm = get_llm()
llm = get_llm(caller="modify_jrxml")
# 构建对话上下文:压缩摘要 + 最近对话
compressed = state.get("compressed_history", "")
recent = state.get("conversation_history", [])[-6:]
@@ -405,6 +480,7 @@ def modify_jrxml(state: AgentState) -> Dict:
return state
@log_node("validate")
def validate(state: AgentState) -> Dict:
"""根据 FastAPI 验证服务验证当前 JRXML。"""
jrxml = state.get("current_jrxml", "")
@@ -448,9 +524,10 @@ def validate(state: AgentState) -> Dict:
return state
@log_node("explain_error")
def explain_error(state: AgentState) -> Dict:
"""生成验证错误的可读解释。"""
llm = get_llm()
llm = get_llm(caller="explain_error")
jrxml = state.get("current_jrxml", "")
lines = jrxml.split("\n")[:80]
snippet = "\n".join(lines)
@@ -464,12 +541,13 @@ def explain_error(state: AgentState) -> Dict:
return state
@log_node("correct_jrxml")
def correct_jrxml(state: AgentState) -> Dict:
"""尝试自动修正验证失败的 JRXML。"""
from langgraph.config import get_stream_writer
writer = get_stream_writer()
llm = get_llm()
llm = get_llm(caller="correct_jrxml")
prompt = load_prompt("correction").format(
current_jrxml=state.get("current_jrxml", ""),
error_msg=state.get("error_msg", ""),
@@ -495,6 +573,7 @@ def correct_jrxml(state: AgentState) -> Dict:
return state
@log_node("finalize")
def finalize(state: AgentState) -> Dict:
"""保存最终验证通过的 JRXML 并更新对话历史 + 版本记录。"""
jrxml = state.get("current_jrxml", "")