pipeline-llm/pipeline_llm/accounting.py

185 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""pipeline_llm.accounting — 异步出账循环(owner 模型三方账)。
扫 llm_usage 中 accounting_status='created' 且 status='SUCCEEDED' 的用量流水,
逐条映射模型→产品,调产品层 product_accounting_generic 记账
(产品层内部按 is_self_use 分流:自用只记 PAY* 采购成本;跨机构另记 PAY 客户应付,
折扣精确到产品),成功置 'accounted',失败置 'failed'(三态:created/accounted/failed)。
2026-09-06 用户定夺:status 统一枚举 SUCCEEDED/FAILED/PENDING/RUNNING;
只有 SUCCEEDED 的行才写 accounting_status(其余 NULL),出账循环自然只拾取 SUCCEEDED。
与账号/存储的同步记账分工明确(2026-09 用户定夺):
- 账号/存储:purchase_realtime 同步记账
- 模型(本模块):调用时只记成本侧 + created 流水,出账走本循环(异步)
单循环守卫(多进程部署时防重复出账):Redis SETNX llm_acc:lock(db4,与限流同库)。
"""
import asyncio
import json
import logging
import time
from ahserver.serverenv import ServerEnv
from .gateway import _get_db, _redis
logger = logging.getLogger("pipeline_llm.accounting")
_SCAN_INTERVAL = 60 # 扫描间隔(秒)
_BATCH = 200 # 每批处理条数
_LOCK_KEY = 'llm_acc:lock'
_LOCK_TTL = 90 # 锁 TTL(略大于单批预算耗时)
async def _get_pending(sor):
recs = await sor.sqlExe(
"SELECT id, org_id, user_id, model_id, req_tokens, resp_tokens, cost, ppid, usages, "
"created_at "
"FROM llm_usage WHERE accounting_status='created' AND status='SUCCEEDED' "
"ORDER BY created_at LIMIT %d" % _BATCH, {})
await sor.sqlExe("COMMIT", {})
return recs or []
async def _find_product_id(sor, model_id):
"""llm_model.id → product.id(产品层导入时按 resource_ref_id 建立)。"""
recs = await sor.sqlExe(
"SELECT id FROM product WHERE resource_ref_id=${r}$ AND status='1' LIMIT 1",
{"r": model_id})
await sor.sqlExe("COMMIT", {})
return (getattr(recs[0], 'id', '') or '') if recs else ''
async def _settle_one(sor, row):
"""单条出账。成功置 accounted;失败置 failed 并记录原因(不静默重试死循环)。"""
env = ServerEnv()
model_id = getattr(row, 'model_id', '') or ''
product_id = await _find_product_id(sor, model_id)
if not product_id:
await sor.U('llm_usage', {
'id': row.id, 'accounting_status': 'failed',
'note': '出账失败:模型(%s)未映射到产品,请先在产品管理导入产线模型' % model_id,
})
await sor.sqlExe("COMMIT", {})
logger.warning("pipeline_llm.accounting: model %s 无产品映射,流水 %s 置 failed",
model_id, row.id)
return False
usage_data: dict = {
'prompt_tokens': int(getattr(row, 'req_tokens', 0) or 0),
'completion_tokens': int(getattr(row, 'resp_tokens', 0) or 0),
}
# 非 token 计价因子(视频时长/分辨率/模型名等,2026-09-05):
# llm_usage.usages JSON 合并进定价引擎输入,按量计费模型(视频/图像)用
usages_raw = getattr(row, 'usages', '') or ''
if usages_raw:
try:
extra = json.loads(usages_raw)
if isinstance(extra, dict):
usage_data.update(extra)
except Exception:
pass
# derived 依赖归一(2026-09-07 根治):token 定价的 cached_tokens/uncache_tokens
# 靠 YAML derived 变量引用 prompt_tokens_details.cached_tokens 计算——该键缺失时
# DictObject 属性链抛 AttributeError,引擎兜底成 0,输入 token 永不计费
# (实测 qwen3.8-max uncache_tokens=0 记账失败根因之一)。缺失补 0 结构,
# derived 正常求值:无缓存时 uncache=prompt_tokens、cached=0。
ptd = usage_data.get('prompt_tokens_details')
if not isinstance(ptd, dict):
usage_data['prompt_tokens_details'] = {'cached_tokens': 0}
elif ptd.get('cached_tokens') is None:
ptd['cached_tokens'] = 0
# 忙闲时计价维度(2026-09-07):价目表存在忙时(8点-22点)/闲时(22点-次日8点)
# 两档定价(deepseek-v4-pro-0813),按调用时刻 llm_usage.created_at 的小时数注入
# hour——YAML 用 hour 区间 filters 选档。多余键对不用它的定价方案无影响
# (引擎只严格检查定价项里出现的维度键)。
created_at = getattr(row, 'created_at', None)
if created_at is not None:
try:
usage_data['hour'] = int(created_at.hour)
except Exception:
pass
fn = getattr(env, 'product_accounting_generic', None)
if fn is None:
# 产品模块未加载 → 保留 created 下轮再试
return False
result = await fn(
product_id=product_id,
usage_data=usage_data,
userorgid=getattr(row, 'org_id', '') or '',
userid=getattr(row, 'user_id', '') or '',
)
if not result or not result.get('success', result.get('orderid')):
msg = str((result or {}).get('message', result))[:180]
await sor.U('llm_usage', {
'id': row.id, 'accounting_status': 'failed',
'note': '出账失败:%s' % msg,
})
await sor.sqlExe("COMMIT", {})
logger.error("pipeline_llm.accounting: 流水 %s 出账失败: %s", row.id, msg)
return False
# 回填客户应付 + 置已出账
await sor.sqlExe(
"UPDATE llm_usage SET charge=${c}$, accounting_status='accounted', "
"note=CONCAT(COALESCE(note,''),'|accounted:',${o}$) WHERE id=${i}$",
{"c": float((result or {}).get('customer_amount', 0) or 0),
"o": str((result or {}).get('orderid', ''))[:40],
"i": row.id})
await sor.sqlExe("COMMIT", {})
return True
async def _run_one_pass():
"""一轮扫描-出账。返回处理条数。"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
rows = await _get_pending(sor)
if not rows:
return 0
ok_n = 0
for row in rows:
try:
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
if await _settle_one(sor, row):
ok_n += 1
except Exception as e:
logger.error("pipeline_llm.accounting: 流水 %s 出账异常: %s",
getattr(row, 'id', ''), e)
try:
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
await sor.U('llm_usage', {
'id': row.id, 'accounting_status': 'failed',
'note': '出账异常:%s' % str(e)[:180],
})
await sor.sqlExe("COMMIT", {})
except Exception:
pass
return ok_n
async def llm_usage_accounting_loop():
"""后台循环:抢分布式锁 → 出账一批 → 睡。永不抛出。
只由独立记账进程(app/pipeline_accounting_worker.py)运行,
不挂进 web 服务器的事件循环(同步记账在 web,异步记账在独立进程)。
"""
logger.info("[pipeline_llm] 异步出账循环已启动(间隔 %ds)", _SCAN_INTERVAL)
while True:
try:
r = _redis()
if r is not None:
# 抢锁(多进程只跑一个);Redis 故障则直接跑(与限流 fail-open 一致)
got = False
try:
got = bool(r.set(_LOCK_KEY, str(time.time()), nx=True, ex=_LOCK_TTL))
except Exception:
got = True
if not got:
await asyncio.sleep(_SCAN_INTERVAL)
continue
await _run_one_pass()
except Exception as e:
logger.error("pipeline_llm.accounting: 循环异常(继续): %s", e)
await asyncio.sleep(_SCAN_INTERVAL)