"""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)