- json/llm_call_trace_list.json: noedit只读列表;exclouded隐藏id/req_path/resp_path/org_id;
url/note加宽;kind/method内联枚举(含gen同步生成,此前草稿引用的get_llm_kind_options.dspy不存在);
删color_mapping(bricks全库0消费的死配置);toolbar查看IO+binds urlwidget→PopupWindow(85%)
url不带?id=${id}$——生成期ArgsConvert会eval成<built-in function id>垃圾串,
行数据id经_add_event_data自动并入params(dapi/llmage生产同款)
- api/llm_call_trace_io.dspy: 薄代理(get_user门禁+get_userorgid机构隔离)→env.llm_call_trace_io;
修草稿4处实锤bug:get_user未await/user.get当dict/sor块外使用/返回PopupWindow套娃
- init.py: llm_call_trace_io读表+同批次+llm_usage上下文(块内读全拷dict)→读文件
(trace.trace_root realpath前缀校验防穿越)→TabPanel三tab(摘要Html+pre escape防注入/
请求/响应CodeEditor readonly mode=null);单侧150K截断提示盘上路径;env注册
- api/llm_project_cost_query.dspy: 改{total,rows}契约接Tabular data_url(PageDataLoader);
wwwroot/llm_project_cost/index.ui: InlineForm(date_from/date_to/status)+Tabular;
script内getWidgetById起点bricks.app(弹窗DOM挂body下,app.root搜不到)
- card_popup.dspy: titles加llm_call_trace;新增page_targets白名单挂按项目费用页;
index.ui挂两张卡片(调用原文追踪/按项目费用)
- accounting.py: _maybe_cleanup_traces每日一次接cleanup_expired(trace.py铁律3
文档承诺worker每日调但循环没接,补齐);放抢锁前(清理幂等多进程同日无害)
- scripts/load_path.py: 注释说明/**通配已覆盖新页面(rbac check_roles_path前缀匹配)
- models/mysql.ddl/README/design-spec: 9张表同步(kind补gen枚举;3.9节;F19/F20)
- scripts/test_llm_call_trace_io.py: stub harness 22断言全过(隔离/穿越/转义/截断/每日门控)
- .gitignore: wwwroot/llm_call_trace/生成目录不入库
193 lines
7.9 KiB
Python
193 lines
7.9 KiB
Python
"""pipeline_llm.trace — 模型调用上行/下行原文追踪(2026-09-10 用户定夺)。
|
||
|
||
存储结构(唯一收敛点:所有路径的上游 HTTP 往返都经 inference._post_upstream /
|
||
_http_request,在这一层各记一次,全链唯一):
|
||
文件:filesroot/llm_trace/<YYYYMMDD>/<call_id>_<seq>.req.json / .resp.json
|
||
库: llm_call_trace 一行一次上游往返(重试/异步轮询的中间轮次都落,1:N)
|
||
|
||
call_id 关联链:chat_inference 入口生成 → ctx['call_id'] → 每次往返落 trace 行
|
||
→ govern_settle 写 llm_usage.call_id(同值)。按 usage 流水即可反查全部原文。
|
||
|
||
铁律:
|
||
1. 脱敏后落盘——Authorization/api-key 等敏感 header 值替换为 [REDACTED]
|
||
(真 key 不出进程;文件不得成为密钥泄露面)。URL 去 query(可能带签名)。
|
||
2. 先写文件、后写库行(行含文件路径);任何失败只记日志绝不阻断调用——
|
||
审计尽力而为,禁止反噬计费/调用链。record_roundtrip 永不抛异常。
|
||
3. 保留期:TRACE_RETENTION_DAYS 天,cleanup_expired 删过期日期目录+库行
|
||
(记账 worker 循环每日调一次)。文件须纳入部署备份(dbackup 只备库,
|
||
filesroot/llm_trace 需另行同步备份,否则"唯一存储"不成立)。
|
||
4. 下载走专用端点 wwwroot/api/llm_trace_file.dspy(登录 + 机构隔离 +
|
||
路径必须落在 llm_trace 目录内)。禁止复用 /idfile——那是登录即可的
|
||
全平台通用口,会把跨机构会话原文泄露给任意登录用户。
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import shutil
|
||
from datetime import datetime, timedelta
|
||
|
||
logger = logging.getLogger("pipeline_llm.trace")
|
||
|
||
_TRACE_DIR = 'llm_trace'
|
||
TRACE_RETENTION_DAYS = 90
|
||
|
||
# 敏感 header 关键词(子串匹配,全小写比较):命中即值替换 [REDACTED]
|
||
_SENSITIVE_HEADER_KEYS = (
|
||
'authorization', 'api-key', 'apikey', 'x-api-key', 'proxy-authorization',
|
||
'cookie', 'x-goog-api-key', 'x-auth-token', 'x-dashscope-apikey',
|
||
'x-aliyun-api-key', 'token',
|
||
)
|
||
|
||
|
||
def _filesroot():
|
||
from appPublic.jsonConfig import getConfig
|
||
root = None
|
||
try:
|
||
root = getConfig().filesroot
|
||
except Exception:
|
||
root = None
|
||
return os.path.abspath(root or __import__('tempfile').gettempdir())
|
||
|
||
|
||
def trace_root():
|
||
"""llm_trace 目录绝对路径(下载端点做路径穿越校验用)。"""
|
||
return os.path.join(_filesroot(), _TRACE_DIR)
|
||
|
||
|
||
def _redact_headers(headers):
|
||
out = {}
|
||
for k, v in (headers or {}).items():
|
||
lk = str(k).lower()
|
||
if any(s in lk for s in _SENSITIVE_HEADER_KEYS):
|
||
out[k] = '[REDACTED]'
|
||
else:
|
||
out[k] = v
|
||
return out
|
||
|
||
|
||
def _dump(obj):
|
||
try:
|
||
return json.dumps(obj, ensure_ascii=False, default=str, indent=None)
|
||
except Exception:
|
||
return str(obj)[:65535]
|
||
|
||
|
||
async def _write_file(rel, content):
|
||
import aiofiles
|
||
fp = os.path.join(_filesroot(), rel)
|
||
os.makedirs(os.path.dirname(fp), exist_ok=True)
|
||
async with aiofiles.open(fp, 'w', encoding='utf-8') as f:
|
||
await f.write(content)
|
||
|
||
|
||
async def record_roundtrip(ctx, seq, kind, url, method, headers, req_body,
|
||
status_code, resp_data, elapsed_ms, err=''):
|
||
"""一次上游 HTTP 往返 → 请求/响应两个文件 + 一行 llm_call_trace。
|
||
|
||
ctx govern_resolve 返回的上下文(须含 call_id;缺 call_id 直接跳过——
|
||
没有关联键的孤儿 trace 无意义)
|
||
seq 往返序号(同步重试 0..2;异步提交 0,轮询 1..N)
|
||
kind chat / gen(同步生成) / submit / query
|
||
resp_data 响应体(dict/str 均可;网络异常时 None)
|
||
err 错误摘要(网络异常/非200 等)
|
||
|
||
永不抛出(铁律 2)。
|
||
"""
|
||
try:
|
||
ctx = ctx or {}
|
||
call_id = str(ctx.get('call_id') or '').strip()
|
||
if not call_id:
|
||
return
|
||
day = datetime.now().strftime('%Y%m%d')
|
||
base = '%s/%s/%s_%03d' % (_TRACE_DIR, day, call_id, int(seq or 0))
|
||
# URL 去 query(可能带临时签名参数,属敏感信息)
|
||
safe_url = str(url or '').split('?')[0][:500]
|
||
req_path = ''
|
||
resp_path = ''
|
||
try:
|
||
req_rel = base + '.req.json'
|
||
await _write_file(req_rel, _dump({
|
||
'url': safe_url, 'method': method, 'kind': kind,
|
||
'headers': _redact_headers(headers),
|
||
'body': req_body,
|
||
}))
|
||
req_path = req_rel
|
||
except Exception as e:
|
||
# 请求文件失败不阻断响应文件与库行(部分留痕优于无痕)
|
||
print("[trace] 请求文件写入失败(call=%s seq=%s): %s" % (call_id, seq, e))
|
||
try:
|
||
resp_rel = base + '.resp.json'
|
||
await _write_file(resp_rel, _dump({
|
||
'status_code': int(status_code or 0),
|
||
'elapsed_ms': int(elapsed_ms or 0),
|
||
'error': err or '',
|
||
'body': resp_data,
|
||
}))
|
||
resp_path = resp_rel
|
||
except Exception as e:
|
||
print("[trace] 响应文件写入失败(call=%s seq=%s): %s" % (call_id, seq, e))
|
||
# 库行(先文件后行:行存在 ⇒ 文件已尝试写入;路径为空=该侧写失败)
|
||
from appPublic.uniqueID import getID
|
||
from .gateway import _get_db
|
||
db, dbname = _get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
await sor.C('llm_call_trace', {
|
||
'id': getID(),
|
||
'call_id': call_id,
|
||
'seq': int(seq or 0),
|
||
'kind': str(kind or '')[:20],
|
||
'url': safe_url,
|
||
'method': str(method or '')[:10],
|
||
'status_code': int(status_code or 0),
|
||
'elapsed_ms': int(elapsed_ms or 0),
|
||
'req_path': req_path,
|
||
'resp_path': resp_path,
|
||
'org_id': ctx.get('org_id') or '',
|
||
'note': str(err or '')[:200],
|
||
})
|
||
await sor.sqlExe("COMMIT", {})
|
||
except Exception as e:
|
||
logger.warning("pipeline_llm.trace: trace 记录失败(不阻断调用): %s", e)
|
||
|
||
|
||
async def cleanup_expired(days=None):
|
||
"""清理过期 trace:按日期目录整删文件 + 删对应库行。返回 (文件目录数, 库行数)。
|
||
|
||
目录名即日期(YYYYMMDD),过期判定以目录名为准(不逐文件 stat);
|
||
库行按 created_at 删。目录删失败(权限/占用)不阻断库行清理。
|
||
"""
|
||
days = int(days or TRACE_RETENTION_DAYS)
|
||
cutoff_day = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d')
|
||
cutoff_ts = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d %H:%M:%S')
|
||
removed_dirs = 0
|
||
root = trace_root()
|
||
try:
|
||
for name in sorted(os.listdir(root)):
|
||
if not (name.isdigit() and len(name) == 8):
|
||
continue
|
||
if name < cutoff_day:
|
||
try:
|
||
shutil.rmtree(os.path.join(root, name))
|
||
removed_dirs += 1
|
||
except Exception as e:
|
||
logger.warning("pipeline_llm.trace: 清理目录 %s 失败: %s", name, e)
|
||
except FileNotFoundError:
|
||
pass
|
||
removed_rows = 0
|
||
try:
|
||
from .gateway import _get_db
|
||
db, dbname = _get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
await sor.sqlExe(
|
||
"DELETE FROM llm_call_trace WHERE created_at<${c}$", {"c": cutoff_ts})
|
||
await sor.sqlExe("COMMIT", {})
|
||
recs = await sor.sqlExe("SELECT ROW_COUNT() AS n", {})
|
||
removed_rows = int(getattr(recs[0], 'n', 0)) if recs else 0
|
||
except Exception as e:
|
||
logger.warning("pipeline_llm.trace: 清理库行失败: %s", e)
|
||
if removed_dirs or removed_rows:
|
||
logger.info("pipeline_llm.trace: 清理过期 trace 目录 %d 个、库行 %d 条(保留 %d 天)",
|
||
removed_dirs, removed_rows, days)
|
||
return removed_dirs, removed_rows
|