Fix/llm parsing and null output #69

Merged
kdletters merged 11 commits from fix/llm-parsing-and-null-output into codex/ai-game-creator-app 2026-06-26 22:02:40 +08:00
5 changed files with 171 additions and 18 deletions
+4 -1
View File
@@ -9,7 +9,10 @@
"Bash(findstr LISTENING)",
"Bash(npx tsc:*)",
"Bash(lsof -ti:8081)",
"Bash(curl -s http://localhost:8081/health)"
"Bash(curl -s http://localhost:8081/health)",
"Bash(npm -v)",
"Bash(cargo --version)",
"Bash(rustc --version)"
]
}
}
+2
View File
@@ -35,6 +35,7 @@ temp*build*/
/apps/ai-game-creator-shell/src-tauri/gen/
/apps/ai-game-creator-shell/src-tauri/logs/
/apps/ai-game-creator-shell/logs/
/apps/ai-game-creator-shell/.llm-drafts/
/apps/mobile-shell/.expo/
/apps/mobile-shell/.expo-export-smoke/
/server-rs/.spacetimedb/
@@ -54,6 +55,7 @@ temp*build*/
.worktrees/
.rag/
.env.secrets.local
.claude/settings.local.json
spacetime.local.json
deploy/container/api-server.env
deploy/container/worker-smoke/
@@ -0,0 +1,3 @@
pub(crate) mod debug_drafts;
pub(crate) use debug_drafts::*;
@@ -0,0 +1,100 @@
//! 调试落盘:保存 LLM 原始输出 / 失败输入,便于排查截断、空返回等问题。
//!
//! 本模块用 `#[cfg(all(debug_assertions, not(test)))]` 声明——仅在开发(debug
//! 且非测试构建中编入二进制;生产 release 与 cargo test 下整个模块与其调用处一并被剔除,
//! 不会往仓库写任何文件。
use std::fs;
use std::path::PathBuf;
use crate::unix_millis;
// 找到仓库根(包含 apps/ai-game-creator-shell/src-tauri/Cargo.toml 的目录),
// 以便把草案落到仓库内而非 tmp 项目目录。
fn repo_root() -> Option<PathBuf> {
let mut roots = Vec::new();
if let Ok(cwd) = std::env::current_dir() {
roots.push(cwd);
}
if let Ok(exe) = std::env::current_exe() {
if let Some(parent) = exe.parent() {
roots.push(parent.to_path_buf());
}
}
for root in roots {
for directory in root.ancestors().take(8) {
let marker = directory
.join("apps")
.join("ai-game-creator-shell")
.join("src-tauri")
.join("Cargo.toml");
if marker.is_file() {
return Some(directory.to_path_buf());
}
}
}
None
}
// 把生成器原始返回(含被截断、解析失败的内容)保存到仓库 .llm-drafts/
// 便于排查 “EOF while parsing a string” 这类输出截断问题。尽力而为,不阻断主流程。
pub(crate) fn persist_snapshot(raw_content: &str) {
let Some(repo_root) = repo_root() else {
eprintln!("llm.draft.snapshot.skip: 未能定位仓库根目录");
return;
};
let dir = repo_root
.join("apps")
.join("ai-game-creator-shell")
.join(".llm-drafts");
if let Err(error) = fs::create_dir_all(&dir) {
eprintln!("llm.draft.snapshot.dir.failed: {}: {error}", dir.display());
return;
}
let path = dir.join(format!("draft-{}.txt", unix_millis()));
if let Err(error) = fs::write(&path, raw_content) {
eprintln!(
"llm.draft.snapshot.write.failed: {}: {error}",
path.display()
);
return;
}
// 同步更新 latest.txt,方便直接打开最近一次草案。
let latest = dir.join("latest.txt");
let _ = fs::write(&latest, raw_content);
eprintln!("llm.draft.snapshot.saved: {}", path.display());
}
// LLM 调用失败(例如返回内容为空)时,把本次发送给模型的输入(system + 完整 user prompt
// 连同错误信息一起落盘到 .llm-drafts/,便于按同样输入复现与定位。尽力而为,不阻断主流程。
pub(crate) fn persist_error_input(system_prompt: &str, user_prompt: &str, error: &str) {
let Some(repo_root) = repo_root() else {
eprintln!("llm.draft.error-input.skip: 未能定位仓库根目录");
return;
};
let dir = repo_root
.join("apps")
.join("ai-game-creator-shell")
.join(".llm-drafts");
if let Err(io_error) = fs::create_dir_all(&dir) {
eprintln!(
"llm.draft.error-input.dir.failed: {}: {io_error}",
dir.display()
);
return;
}
let body = format!(
"# LLM 生成失败输入快照\n\n## 错误\n{error}\n\n## System Prompt\n{system_prompt}\n\n## User Prompt(含用户需求/短长期记忆/spec/agenda/组 brief/findings\n{user_prompt}\n",
);
let path = dir.join(format!("error-input-{}.txt", unix_millis()));
if let Err(io_error) = fs::write(&path, &body) {
eprintln!(
"llm.draft.error-input.write.failed: {}: {io_error}",
path.display()
);
return;
}
// 同步更新 latest-error-input.txt,方便直接打开最近一次失败输入。
let latest = dir.join("latest-error-input.txt");
let _ = fs::write(&latest, &body);
eprintln!("llm.draft.error-input.saved: {}", path.display());
}
@@ -34,6 +34,11 @@ use shared_contracts::game_creation_app::{
use tauri::Emitter;
use tauri_plugin_opener::OpenerExt;
// 调试落盘模块(保存 LLM 原始输出 / 失败输入,排查截断、空返回等)放在 debug_drafts.rs。
// 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。
#[cfg(all(debug_assertions, not(test)))]
mod debug;
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct InitLocalProjectResult {
@@ -364,7 +369,7 @@ const DEFAULT_GAME_INDEX_HTML: &str = r#"<!doctype html>
"#;
const DEFAULT_EDITOR_BASE_URL: &str = "http://127.0.0.1:3000";
const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 5000;
const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 320000;
const GAME_CREATOR_PLANNER_MAX_OUTPUT_TOKENS: u32 = 900;
const GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS: u64 = 180_000;
const GAME_CREATOR_AGENT_LOOP_MAX_PASSES: u8 = 3;
@@ -1465,6 +1470,7 @@ async fn request_llm_game_draft_with_client(
"",
"",
"",
None,
)
.await
}
@@ -1632,6 +1638,7 @@ async fn run_game_creator_agent_loop_at(
&findings_markdown,
&group_briefs_markdown,
&read_optional_text(&root.join(&agenda.relative_path))?,
progress,
)
.await
{
@@ -1814,23 +1821,61 @@ async fn request_generator_game_draft_with_client(
findings_markdown: &str,
group_briefs_markdown: &str,
agenda_markdown: &str,
progress: Option<&AgentProgressEmitter<'_>>,
) -> Result<LlmGameDraft, String> {
let request = LlmTextRequest::new(vec![
LlmMessage::system(game_creator_system_prompt()),
LlmMessage::user(game_creator_generator_user_prompt(
prompt,
short_memory,
long_memory,
spec_markdown,
findings_markdown,
group_briefs_markdown,
agenda_markdown,
)),
])
.with_max_tokens(GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS);
let response = request_game_creator_llm_text(client, request)
.await
.map_err(|error| format!("LLM 生成失败:{error}"))?;
let system_prompt = game_creator_system_prompt();
let user_prompt = game_creator_generator_user_prompt(
prompt,
short_memory,
long_memory,
spec_markdown,
findings_markdown,
group_briefs_markdown,
agenda_markdown,
);
// deepseek-v4-pro 等推理模型偶发返回空 contentHTTP 200、completion_tokens=0
// 参见 DeepSeek-V3 issue #1453)。这类空返回是上游瞬时故障,原样重发通常即可恢复,
// 因此仅对 EmptyResponse 最多重试 3 次(含首次共 4 次请求),其它错误不重试。
const MAX_EMPTY_RETRIES: u32 = 3;
let mut empty_retries = 0u32;
let response = loop {
let request = LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt.clone()),
])
.with_max_tokens(GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS);
match request_game_creator_llm_text(client, request).await {
Ok(response) => break response,
Err(platform_llm::LlmError::EmptyResponse) if empty_retries < MAX_EMPTY_RETRIES => {
empty_retries += 1;
eprintln!(
"llm.chat.generator.empty-response 重试 {empty_retries}/{MAX_EMPTY_RETRIES}(上游返回空 content,原样重发)"
);
// 同步推送到 App 进度面板,便于在界面上看到重试(无需盯命令行)。
emit_agent_progress(
progress,
"llm.generator.empty_retry",
&format!(
"Generator 收到空返回(DeepSeek 偶发),正在自动重试 {empty_retries}/{MAX_EMPTY_RETRIES}"
),
);
continue;
}
Err(error) => {
// 调用失败(含空返回重试耗尽)时,把本次输入连同错误一并落盘,便于复现定位(仅 debug 构建)。
#[cfg(all(debug_assertions, not(test)))]
debug::persist_error_input(
system_prompt,
user_prompt.as_str(),
&error.to_string(),
);
return Err(format!("LLM 生成失败:{error}"));
}
}
};
// 先把原始返回落盘,再解析;解析失败(如输出截断)时仍能从仓库里拿到完整原文(仅 debug 构建)。
#[cfg(all(debug_assertions, not(test)))]
debug::persist_snapshot(response.content.as_str());
let content = strip_llm_thinking_blocks(response.content.as_str());
parse_llm_game_draft_response(content.as_str())
}