185 lines
7.9 KiB
Python
185 lines
7.9 KiB
Python
"""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)
|