pipeline-llm/pipeline_llm/accounting.py

150 lines
5.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='pending' 的用量流水owner 模型、调用方≠平台),
逐条映射模型→产品,调产品层 product_accounting_generic 出三方账
(客户付/商户营收/供应商成本,客户折扣精确到产品),成功置 'accounted'
与账号/存储的同步记账分工明确2026-09 用户定夺):
- 账号/存储purchase_realtime 同步记账
- 模型(本模块):调用时只记成本侧 + pending 流水,出账走本循环(异步)
单循环守卫多进程部署时防重复出账Redis SETNX llm_acc:lockdb4与限流同库
"""
import asyncio
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 "
"FROM llm_usage WHERE accounting_status='pending' AND status='ok' "
"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 = {
'prompt_tokens': int(getattr(row, 'req_tokens', 0) or 0),
'completion_tokens': int(getattr(row, 'resp_tokens', 0) or 0),
}
fn = getattr(env, 'product_accounting_generic', None)
if fn is None:
# 产品模块未加载 → 保留 pending 下轮再试
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)