【独立开发日记 007】Game-Odyssey 踩坑复盘:那些想不到的 Bug

【独立开发日记 007】Game-Odyssey 踩坑复盘:那些想不到的 Bug

做 AI 应用最痛苦的不是技术难,而是各种意想不到的细节问题。

做 Game Odyssey 的过程中,遇到了不少让人抓狂的问题。这篇文章把最典型的几个坑分享出来,希望能帮到同样在做 RAG 应用的朋友。

坑 1:Ollama 连接失败

现象

启动后端,调用聊天 API,返回:

{"detail": "All connection attempts failed"}

前端显示"抱歉,发生了错误。请稍后重试。"

排查

看后端日志:

httpx.ConnectError: All connection attempts failed

原来是连接 Ollama(localhost:11434)失败了。

原因

后端启动时,Ollama 服务没有运行。这在开发时经常发生——重启电脑后忘了启动 Ollama。

解决

1. 添加友好错误提示

except httpx.ConnectError as e:
    error_msg = (
        "无法连接到 AI 模型服务。"
        "请确保 Ollama 服务正在运行。"
        "启动命令: ollama serve"
    )
    raise Exception(error_msg)

2. 前端显示具体错误

把后端的错误信息透传给用户,而不是笼统的"发生错误"。

教训

对外部依赖的连接失败,一定要给出明确的错误信息和解决建议。


坑 2:图片全挂了

现象

游戏卡片的图片全部显示不出来,控制台报 CORS 错误:

Access to image at 'https://img1.gamersky.com/xxx.jpg' from origin 'http://localhost:5173' has been blocked by CORS policy

原因

游戏图片来自外部域名(gamersky.com),浏览器的同源策略阻止了跨域请求。

解决

后端添加图片代理

@router.get("/images/proxy")
async def proxy_image(url: str):
    # 验证 URL 在白名单域名
    if not is_allowed_domain(url):
        raise HTTPException(403, "不允许的图片域名")
    
    # 代理请求图片
    async with httpx.AsyncClient() as client:
        response = await client.get(url)
        return Response(
            content=response.content,
            media_type=response.headers.get("Content-Type")
        )

前端自动走代理

function getProxiedImageUrl(url: string): string {
    if (needsProxy(url)) {
        return `/api/v1/images/proxy?url=${encodeURIComponent(url)}`
    }
    return url
}

教训

涉及外部资源时,CORS 是绑定会遇到的问题。要么让资源方配置 CORS,要么自己做代理。


坑 3:推荐结果与卡片不一致

现象

用户问"推荐类似对马岛之魂的游戏":

  • 文字推荐:原神、巫师3、地平线
  • 卡片显示:对马岛之魂、原神、巫师3

卡片里居然有用户提到的"对马岛之魂"!

原因

这是两个问题叠加:

问题 A:卡片显示的是向量检索结果,不是 LLM 推荐的

向量检索找"最相似"的游戏,《对马岛之魂》和它自己当然最相似,所以排第一。

问题 B:LLM 推荐的游戏和卡片对不上

LLM 可能"自由发挥"推荐了检索结果之外的游戏。

解决

1. 检索更多,让 LLM 选

# 检索 10 个候选
candidates = await search_similar_games(query, limit=10)
 
# 让 LLM 从中选 3 个
prompt = f"""
候选游戏:{candidates}
用户需求:{query}
 
请选择 3 款最合适的推荐。
注意:如果用户提到"类似 XX",不要推荐 XX 本身。
"""

2. 解析 LLM 返回的游戏 ID

让 LLM 按格式输出:

---游戏ID--- 123,456,789 ---游戏ID结束---

后端解析这些 ID,再返回对应的游戏信息。

3. 从用户问题中提取游戏名

def extract_game_titles_from_query(query: str) -> set:
    """提取用户问题中提到的游戏"""
    excluded = set()
    
    # 匹配《游戏名》格式
    matches = re.findall(r'《([^》]+)》', query)
    excluded.update(matches)
    
    # 匹配"类似 XX"格式
    matches = re.findall(r'类似([^\s,,。]+)', query)
    excluded.update(matches)
    
    return excluded

4. 多层 fallback

如果 LLM 没按格式输出,就从文字中匹配游戏名。

教训

RAG 系统的"检索"和"生成"是两个独立过程,要确保最终展示的内容一致。LLM 的输出不可控,必须有 fallback 策略。


坑 4:LLM 输出带格式标记

现象

推荐文字里出现了 **ID:76** 这样的标记:

  1. ID:76 对马岛之魂 (Ghost of Tsushima):这款游戏...

原因

Prompt 里要求 LLM 输出游戏 ID,但它把 ID 也写进了推荐文字里。

解决

后处理时清理这些标记:

# 清除 ID 标记
response_text = re.sub(r'\*?\*?\[?ID:\d+\]?\*?\*?\s*', '', response_text)

教训

LLM 的输出永远不可预测。任何格式要求都可能被"创造性"地执行。要在后处理时清理可能的噪音。


总结

做 RAG 应用的几个关键经验:

  1. 外部依赖要有降级方案:Ollama 没启动、网络不通,都要给用户明确提示
  2. CORS 问题要提前考虑:涉及外部资源就做代理
  3. 检索和生成要对齐:确保最终展示的内容一致
  4. LLM 输出要后处理:清理格式噪音、多层 fallback

完整代码已开源:https://github.com/ICEMAN-CN/game-reco.git