150 lines
5.9 KiB
Python
150 lines
5.9 KiB
Python
"""pipeline_llm.accounting — 异步出账循环(owner 模型三方账)。
|
||
|
||
扫 llm_usage 中 accounting_status='pending' 的用量流水(owner 模型、调用方≠平台),
|
||
逐条映射模型→产品,调产品层 product_accounting_generic 出三方账
|
||
(客户付/商户营收/供应商成本,客户折扣精确到产品),成功置 'accounted'。
|
||
|
||
与账号/存储的同步记账分工明确(2026-09 用户定夺):
|
||
- 账号/存储:purchase_realtime 同步记账
|
||
- 模型(本模块):调用时只记成本侧 + pending 流水,出账走本循环(异步)
|
||
|
||
单循环守卫(多进程部署时防重复出账):Redis SETNX llm_acc:lock(db4,与限流同库)。
|
||
"""
|
||
|
||
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)
|