8 人桌德州扑克:1 名真人 vs 7 名 LLM 驱动的 AI。每个 AI 有独立人设、独立长期记忆、独立反思与策略迭代——不是会下棋的 LLM,是会自我修正的 LLM。
| 维度 | 大多数 LLM-poker 项目 | 本项目 |
|---|---|---|
| AI 决策 | 单次 LLM 调用,无记忆 | 每个 AI 拥有独立 output/agent_memory/{agent_id}.db,10 张表的分层记忆 |
| 多 AI 区分 | 模型温度或简短系统提示 | 人格 7 维数值 (bluff_frequency / aggression / tightness / skill_level / ...) → 中文 system prompt + temperature 微调 |
| 对手建模 | 没有,或仅本手内 | 跨手 per-street 统计(VPIP / PFR / 3-bet / fold-to-3bet / c-bet / fold-to-cbet / 摊牌胜率 / 平均下注比例 / 激进度),按时间衰减 |
| 反思 | 无,或单条字符串拼回 prompt | 结构化反思:kind ∈ {mistake, exploit, range_update, self_review, lesson} + topic + confidence + 句向量;置信度二次校正(小样本→强制 low) |
| 长期策略迭代 | 重启即丢 | 每 20 手或反思 > 100 条触发 distillation:高频 topic 的多条反思被 LLM 压缩成 knowledge 规则,源反思标记 superseded_by 不再纳入检索 |
| Prompt 装配 | 一次性塞满,无预算 | 按 spot embedding 检索 top-K 反思/关键决策点,余弦 + 时间衰减 + 置信度加权排序,整段 ≤1500 tokens |
| 隐私正确性 | 经常给 AI"上帝视角" | 每个 AI 的 hand log 只含它能观测的信息——公开动作 + 自己手牌 + showdown 摊到的牌;不给 AI 它看不到的卡牌 |
| LLM 接入 | OpenAI 或 Anthropic 写死 | Anthropic SDK 指向 DeepSeek 兼容端点;模型名/超时/QPS 限流可配;任何环节失败都降级到 personality 启发式引擎而不是默认 fold |
| 嵌入 | 调商用 embedding API | 本地 sentence-transformers (BAAI/bge-small-zh-v1.5,95MB),零调用成本,离线可用;不可用时自动退到 recency + confidence 排序 |
| UI | 仅牌桌 | 右下角本场历史看盘:每手摘要卡 + 三键控制(继续 / 暂停 / 重新开始);重新开始只清显示,不动 SQLite 长期记忆 |
第 N 手轮到 Alice(TAG,紧手 + 激进)翻牌后决策:
hand_action_log + GameState
│
▼
LLMDecisionEngine.make_decision
│ 1. 构造 spot 描述: "flop pos=3/8 hole=As Ks board=Qd Jc 2h pot=400 to_call=200"
│ 2. EmbedderClient 本地嵌入 spot
│ 3. SQLite 查询:
│ opponent_snapshot (当前桌 7 人的 VPIP/PFR/c-bet/激进度)
│ knowledge (top-2 已蒸馏规则,cos+conf 排序)
│ reflections (top-4 相似历史反思,kind ∈ {exploit,lesson,range_update})
│ notable_spots (top-2 同 street 关键决策点)
│ recent_session (最近 20 手净盈亏)
│ 4. pack_into_budget(sections, 1500 tokens)
│ 5. build_system_prompt(personality, agent_id, memory_block)
│ ↓ 静态部分带 cache_control:ephemeral(LLM 提示词缓存)
▼
LLMClient.decide(system, user) ← DeepSeek-v4-flash, anthropic 兼容端点
│ timeout=15s/attempt, 2 retries, token-bucket QPS=5
│
▼
JSON {"action":"raise","amount":500,"reason":"AKs 顶顺听 + 听花,对 Carol 的下注 raise 取得范围优势"}
│
▼
_coerce_decision (合法性夹紧:amount 钳到 [min_raise, stack],
check-when-bet-pending → 改 call,all-in 重写为 raise=stack)
│
▼
engine.process_action → 状态推进 → 广播
LLM 任何一环失败(网络、超时、JSON 解析、非法动作)都走 AIDecisionEngine(personality 数值启发式)兜底。绝不强制 FOLD——这是上一轮 bug 修复的核心。
hand_complete
│
▼
ai/stats.compute_per_player_features(hand_log)
│ → 每个玩家的 per-street features (VPIP / PFR / 3bet / fold-to-3bet /
│ c-bet as aggressor / fold-to-cbet / barrel-2nd / barrel-3rd /
│ showdown 参与 + 胜负 / 平均下注比例)
▼
ai/stats.detect_notable_spots()
│ → 自我视角下的"关键决策点"(all-in、3-bet+、河牌大注、≥15 BB 大底池)
▼
SQLite 单事务: hands + actions + showdown_reveals +
opponent_observations + opponent_stats(增量) +
notable_spots + sessions(净盈亏累计)
▼
Reflector (后台 fire-and-forget):
LLM 一次返回 ≤3 条结构化反思 {kind, topic, text, confidence, ref_opp}
→ confidence 二次校正
→ batch embed 一次写 BLOB
→ 与最近 30 条 cos > 0.92 视为重复 → merge + used_count++
│
▼ (每 20 手 / 100 条反思触发)
distill_recent_reflections:
按 topic 分桶,每桶 LLM 压缩成 ≤3 条 knowledge 规则
源 reflections 标 superseded_by,不再入检索
下一手开始时,新 spot 嵌入向量去与这些反思/关键点做余弦检索,自然回流到 system prompt——这就是"策略迭代"的物理实现。
| 座位 | 角色 | style | 紧手度 | 激进度 | 诈唬频率 | 技术 |
|---|---|---|---|---|---|---|
| Alice | 外科医生 THE SURGEON | tight_aggressive | 0.78 | 0.72 | 0.14 | 0.76 |
| Bob | 浪客 THE MAVERICK | loose_aggressive | 0.18 | 0.86 | 0.28 | 0.57 |
| Carol | 图书馆员 THE LIBRARIAN | tight_passive | 0.74 | 0.24 | 0.05 | 0.60 |
| David | 度假客 THE TOURIST | loose_passive | 0.22 | 0.20 | 0.03 | 0.44 |
| Eve | 新手 THE NEWBIE | fish | 0.34 | 0.42 | 0.18 | 0.22 |
| Frank | 鲨鱼 THE SHARK | pro | 0.58 | 0.66 | 0.12 | 0.95 |
| Grace | 磐石 THE ROCK | nit | 0.96 | 0.18 | 0.02 | 0.72 |
数值通过 prompt_builder._style_brief() 转中文人设段落 + temperature 微调 (0.7 + (bluff_frequency - 0.15) × 0.3),所以 7 个 AI 共享同一个模型但行为分布显著不同。实际跑 30 手后,Alice 写"作为 TAG 应持续下注施压"、Bob 写"激进度偏低应更多诈唬"、Grace 写"本手 fold 符合 nit 策略"——人设固化在自我反思里。
poker/
├── server.py # FastAPI + WebSocket + 牌桌编排 + 暂停/继续闸门
├── core/ # 引擎(与 AI 解耦)
│ ├── engine.py # 状态机、动作合法性、街道推进、摊牌结算
│ ├── pot_manager.py # 主池/边池
│ ├── hand_evaluator.py # 牌力评估
│ └── deck.py
├── models/ # 数据模型
│ ├── game_state.py # GameState + hand_action_log + dealt_hole_cards 快照
│ ├── player.py # Player + ActionType
│ └── card.py
├── ai/ # 全部 LLM 相关 + 记忆
│ ├── personalities.py # 7 个 frozen dataclass
│ ├── decision_engine.py # 启发式引擎 (failure fallback)
│ ├── llm_client.py # Anthropic SDK 封装 + token bucket + 重试 + JSON 解析
│ ├── llm_decision.py # LLM 决策入口(与启发式同签名,便于无感降级)
│ ├── prompt_builder.py # system / user / reflection / memory_block_v2
│ ├── memory.py # AgentMemory facade,分发到 v1 JSON / v2 SQLite
│ ├── memory_schema.sql # 10 张表 DDL (WAL + 索引)
│ ├── memory_migrations.py # v1 JSON → v2 SQLite 迁移
│ ├── stats.py # 纯函数:hand_log → per-street features + notable_spots
│ ├── reflection.py # 后手末结构化反思 + 嵌入 + 去重
│ ├── distillation.py # 反思 → knowledge 蒸馏
│ ├── embedder.py # 本地 sentence-transformers + 余弦 + 降级
│ ├── retrieval.py # top-K + 时间衰减 + confidence boost + token budget
│ └── adapter.py
├── static/ # 原生 HTML + JS + CSS(无构建工具)
│ ├── index.html
│ ├── app.js # WebSocket 状态机 + 历史面板渲染 + 控制按钮
│ └── style.css
└── tests/ # pytest,POKER_LLM_DISABLED=1 下完全离线可跑
# 1. 装依赖(含 anthropic、sentence-transformers)
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
# 代理用户可能需要:pip install "httpx[socks]"
# 2. 复制 .env.example → .env,填入你的 DeepSeek API key
cp .env.example .env
$EDITOR .env
# 3. 预下载嵌入模型(首次约 95 MB,缓存到 ~/.cache/huggingface/)
python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('BAAI/bge-small-zh-v1.5')"
# 4. 启服务
PYTHONPATH=. python -m uvicorn server:app --host 127.0.0.1 --port 8001浏览器开 http://127.0.0.1:8001,点 START GAME。每手结束右下角看历史卡和三键控制。
| 变量 | 默认 | 说明 |
|---|---|---|
DEEPSEEK_API_KEY |
(必填) | 留空则全部 AI 走启发式 |
DEEPSEEK_MODEL |
deepseek-v4-flash |
任何 anthropic 兼容模型名 |
DEEPSEEK_BASE_URL |
https://api.deepseek.com/anthropic |
改它就能换 provider |
POKER_LLM_TIMEOUT |
15.0 |
单次 LLM HTTP 超时(DeepSeek-flash p50≈10-15s) |
POKER_AI_DECISION_TIMEOUT |
30.0 |
决策外层 wall-clock 上限,超时走启发式 |
POKER_REFLECTION_TIMEOUT |
30.0 |
后台反思批次总超时 |
POKER_LLM_DISABLED |
0 |
设 1 完全离线,全 AI 走启发式 |
POKER_MEMORY_V2 |
1 |
SQLite 后端(设 0 退到旧 JSON 快照) |
POKER_MEMORY_RETRIEVAL |
1 |
嵌入检索(设 0 退到 recency + confidence 排序) |
EMBEDDING_MODEL |
BAAI/bge-small-zh-v1.5 |
可换 bge-m3 (多语 1024d) 或 all-MiniLM-L6-v2 (英文 80MB) |
# 全量离线回归(mock LLM)
PYTHONPATH=. POKER_LLM_DISABLED=1 python -m pytest tests/ -q
# 三种配置组合
PYTHONPATH=. POKER_LLM_DISABLED=1 pytest tests/ -q # v1 JSON
PYTHONPATH=. POKER_LLM_DISABLED=1 POKER_MEMORY_V2=1 pytest tests/ -q # v2 SQLite
PYTHONPATH=. POKER_LLM_DISABLED=1 POKER_MEMORY_V2=1 POKER_MEMORY_RETRIEVAL=1 pytest tests/ -q # v2 + 检索104 个测试覆盖:引擎合法性 / pot manager / 7 personality 区分度 / 决策回归 / v1↔v2 迁移 / per-street features 准确性 / 反思去重 / 蒸馏 / token budget / 暂停-继续-重启控制 / E2E smoke 等。
跑几手后:
# LLM 健康
curl -s http://127.0.0.1:8001/api/metrics | jq
# 单个 AI 的反思与蒸馏规则
sqlite3 output/agent_memory/ai_1.db <<'SQL'
SELECT kind, topic, confidence, substr(text, 1, 60)
FROM reflections WHERE superseded_by IS NULL ORDER BY id DESC LIMIT 5;
SELECT topic, confidence, rule_text FROM knowledge;
SELECT opponent_id, hands_observed,
round(1.0*vpip_hits/nullif(vpip_opps,0), 2) AS vpip,
round(1.0*pfr_hits/nullif(pfr_opps,0), 2) AS pfr,
round(1.0*aggressive_actions/nullif(aggressive_actions+passive_actions,0), 2) AS agg
FROM opponent_stats ORDER BY hands_observed DESC;
SQL- DeepSeek-v4-flash p95 偶发 60-80s,命中外层 30s 超时 → 走启发式 fallback(不再 FOLD)
- 嵌入模型首次启动需要下载 (~95 MB),离线场景需提前
huggingface-cli download BAAI/bge-small-zh-v1.5 - 重新开始按钮只清前端显示,SQLite 长期记忆故意保留——这是设计意图,便于跨 session 持续训练
- 多 tab 共享同一个服务端状态机,一处点
继续影响所有 tab
代码 MIT,嵌入模型权重 Apache-2.0(BAAI)。