到現在,散步詳情頁已經有距離、時間、速度這些數字了。
再加入 AI 時,我不想把整段 GPS、暫停紀錄、近期散步資料全部丟給 AI,因為這樣等於連「要分析什麼」都交給 AI 決定了。
即使最後產生的文字看起來合理,也很難保證它真的是使用者需要的內容。
所以這次我把分析拆成兩個階段:
也就是後端決定「說什麼」,AI 負責「怎麼說」。
使用者開始分析後,後端不會直接把整筆散步資料交給 AI,而是先整理分析需要的資料。
其中第一件事是確認這筆散步是否可靠。
例如平均速度或最高速度出現不合理的數值、移動時間太短,或距離太短,都可能讓這筆散步不適合拿來做比較。
這部分完全由程式判斷:
export const WALK_ANALYSIS_RULES = {
unusualAverageWalkingSpeedMps: 3,
unusualPeakWalkingSpeedMps: 6,
minimumReliableMovingSeconds: 300,
minimumReliableDistanceMeters: 300,
} as const;
type QualityRule = {
issue: WalkAnalysisQuality['issues'][number];
check: (walk: Walk) => boolean;
};
const RULES: QualityRule[] = [
{
issue: 'abnormal_average_speed',
check: (w) =>
w.averageSpeedMps >
WALK_ANALYSIS_RULES.unusualAverageWalkingSpeedMps,
},
{
issue: 'abnormal_max_speed',
check: (w) =>
w.maxSpeedMps >
WALK_ANALYSIS_RULES.unusualPeakWalkingSpeedMps,
},
{
issue: 'too_short',
check: (w) =>
w.movingTimeSeconds <
WALK_ANALYSIS_RULES.minimumReliableMovingSeconds ||
w.distanceMeters <
WALK_ANALYSIS_RULES.minimumReliableDistanceMeters,
},
];
export function evaluateWalkAnalysisQuality(
walk: Walk,
): WalkAnalysisQuality {
const issues = RULES
.filter((rule) => rule.check(walk))
.map((rule) => rule.issue);
const isReliable =
walk.status === 'completed' && issues.length === 0;
return {
isReliable,
issues,
includeInBaseline: isReliable,
};
}
如果資料不可靠,這筆散步仍然可以分析,但不會拿來當作近期散步的比較基準。
近期基準也只會使用可靠的散步紀錄,最多取 10 筆;少於 3 筆時,就不會拿來做需要基準的比較。
除了資料品質,後端還會計算這次散步的速度、暫停比例、前後半段速度變化,以及和近期散步的差異。
這些數值都是後端計算出來的,不是讓 AI 自己猜。
有了這些資料之後,後端會進一步找出「這次散步有哪些地方值得注意」。
這些還不是最後給使用者看的文字,而是分析候選。
程式裡使用 InsightCandidate 表示一個候選重點。它會記錄這個重點的類型、重要程度、可以用來佐證的資料,以及是否值得提醒使用者。
例如可能會出現:
如果資料本身不可靠,則會直接只留下資料品質相關的候選,可靠的資料則會繼續套用其他分析規則。
最後再按照重要程度排序,最多保留 4 個候選。
到這裡,後端已經完成「這次散步值得注意什麼」的判斷,但還沒有讓 AI 自己重新分析。
真正送給 AI 的內容也不是整份 GPS 或原始散步資料,而是後端整理好的候選。
Prompt 會直接限制 AI:
你收到的是 deterministic analysis
已挑選並排序的 Insight Candidates,
不是原始散步資料。
只能逐項解釋 candidates 內的 type 與 evidence,
不可引入其他議題或自行發現 pattern。
這裡的 Insight,指的是從散步資料中整理出來,最值得告訴使用者的一個發現。
所以 AI 拿到的其實已經是這次值得注意的是速度變化。它的工作不是重新判斷今天是不是走得比較慢?而是把前面的分析結果整理成自然、容易理解的文字。
這樣可以避免 AI 從大量資料中自行發現產品原本沒有打算分析的內容。
後端可能找出好幾個候選,但最後只會產生一個主要 Insight。
因為如果一次把速度、暫停、散步時間、路線、GPS 品質全部告訴使用者,很容易變成一份散步報告,而不是一個容易理解的回饋。
所以 AI 最後會整理成:
{
title,
description,
evidence,
suggestion
}
例如:
這也讓後端和 AI 的責任非常清楚:後端負責分析和決定內容,AI 負責把內容表達出來。
AI 如果直接回傳一般文字,程式很難穩定處理。
例如我們希望最後拿到:
{
title,
description,
evidence,
suggestion
}
但如果只是告訴 AI「請用 JSON 回傳」,它仍然可能漏欄位、改欄位名稱,甚至回傳其他格式。
因此這裡使用 Structured Output(結構化輸出),要求 AI 按照指定的 JSON Schema 回傳固定格式。
這次的結果需要包含 hasMeaningfulInsight 和 insights:
const walkAnalysisOutputSchema = z.object({
hasMeaningfulInsight: z.boolean(),
insights: z
.array(
z.object({
title: z.string().min(1).max(80),
description: z.string().min(1).max(300),
evidence: z.string().min(1).max(180).nullable(),
suggestion: z.string().min(1).max(180).nullable(),
}),
)
.max(1),
});
這裡的 .max(1) 就是確保最後最多只有一個分析重點。
不過固定格式不代表內容一定正確,所以收到 AI 結果後,還會再進行驗證。
除了確認資料符合 Schema,也會確認:
insights 必須剛好有一筆也就是:
這幾層其實是在解決不同問題。
Structured Output 管的是「AI 要用什麼格式回答」。
Zod 管的是「程式收到的資料是不是符合我們定義的 Schema」。
最後的分析規則管的是「這段內容有沒有超出後端原本允許 AI 說的範圍」。
如果使用者短時間內重複分析同一筆散步,沒有必要每次都重新呼叫 AI。
所以後端會先把這次分析使用的資料序列化,再計算 SHA-256:
const snapshot = JSON.stringify(context);
const inputHash = await sha256(snapshot);
資料庫會保存這個 inputHash。
下一次分析時,如果分析資料和 Prompt 版本都沒有改變,而且使用者沒有要求重新產生,就直接使用之前成功的結果。
if (
existing &&
!regenerate &&
existing.inputHash === inputHash &&
existing.promptVersion === WALK_ANALYSIS_PROMPT_VERSION
) {
return {
analysis: stripHash(existing),
cached: true,
};
}
這裡 Hash 的不是 AI 最後產生的文字,而是分析時使用的資料。
因此如果近期又多了一筆可靠散步,或其他分析資料發生變化,Hash 也會跟著改變,下一次就會重新分析。
這樣 Cache 不只是單純避免重複呼叫 AI,也能確保分析資料真的改變時,不會一直使用舊結果。
regenerate 重新產生結果如果使用者按下「重新產生」,App 會傳入 regenerate: true
即使已經有相同資料的 Cache,regenerate 也會跳過 Cache,繼續執行後續的生成流程。
不過這不代表讓 AI 自己重新決定分析方向。
後端仍然會重新整理資料、找出分析候選,再交給 AI 產生新的文字。
所以 regenerate 做的是重新產生一次結果,而不是讓 AI 重新找一個問題。
Cache 解決的是「之前已經算過」。
但如果使用者快速按兩次分析,還是可能有兩個請求同時進來。
因此開始生成之前,後端會先確認這筆散步目前是不是已經有分析正在進行。
如果已經有另一個請求正在生成,就回傳 409,避免同一筆散步同時產生兩份結果。
這和 Cache 解決的是不同問題:
AI 不是整個分析流程唯一的依賴。
如果 AI 呼叫失敗、輸出的 JSON 驗證失敗,或內容違反分析規則,後端會使用當下分析資料對應的固定結果作為 fallback。
const fallback = createWalkAnalysisRequest(context).mockValue;
await this.repository.complete(
walkId,
token,
fallback,
generatedAt,
);
這裡的重點是,fallback 也會根據後端已經選出的分析結果產生。
也就是 AI 失敗時,不是重新做一次分析,而是直接用程式把同一個分析結果整理成固定文案。
例如後端已經判斷「暫停比例偏高」,那 AI 失敗時,fallback 也應該描述「暫停比例偏高」,而不是突然變成「這次散步與近期差不多。」
這樣才能確保不管最後是 AI 產生文字,還是使用 fallback,分析方向都由同一套後端規則決定。
如果是使用者主動重新產生時發生失敗,App 也會保留原本已經成功的結果,而不是讓畫面變成空白。
目前 AI 分析的畫面長這樣:


明天要分享的是如果只分析這一次還不夠,那能不能把最近幾次散步放在一起,進一步決定使用者下一次適合怎麼走?