미리보기는 꽉 차 있었는데, 메뉴마다 '자동'이라고 적혀 있었다.

한 달 치 점심은 한 그릇, 저녁은 국과 메인과 반찬 두 가지로 채워 달라고 했다. 메뉴로 치면 150개쯤 된다. 한참 기다린 끝에 미리보기가 떴고, 날짜마다 메뉴가 빠짐없이 들어가 있었다. 그런데 이유 칸이 전부 같은 말이었다. 언어 모델이 고른 메뉴는 하나도 없었다. 화면을 채운 건 모델이 실패했을 때를 대비해 서버에 넣어 둔 보충 규칙이었다.

「식단서랍」 개발기에서는 달력·장보기·추천을 만들고 스토어에 올리기까지를 적었다. 이 연재는 그 뒤의 이야기다. 서비스에 언어 모델 API 를 붙이기 시작했고, 첫 번째가 빈 달력을 채워 주는 'AI 식단'이었다. 그 '자동' 표시가 왜 붙었는지 설명하려면, 이 기능이 처음부터 모델을 얼마나 믿지 않게 만들어졌는지부터 말해야 한다.

모델은 번호만 고른다

처음에 정한 원칙은 하나였다. 모델은 레시피를 지어내지 않는다. 서버가 넘긴 후보 레시피 목록에서 번호만 고른다.

모델은 그럴듯한 요리 이름을 얼마든지 만들어 내지만, 달력에 올라가는 건 재료와 조리 순서가 있는 레시피다. 그래서 요청에는 사용자 조건과 함께 후보 레시피 목록을 통째로 보낸다. 후보 한 줄은 이렇게 생겼다.

sb.AppendLine().AppendLine("## 후보 레시피 (id|이름|종류|주재료|방법|조리분|역할)");
foreach (var c in candidates)
    sb.Append(c.Id).Append('|').Append(c.Title).Append('|').Append(c.Type).Append('|')
      .Append(c.MainIngredient).Append('|').Append(c.Method).Append('|').Append(c.CookingTime).Append('|')
      .AppendLine(MealCourse.RolesFor(c.Type, roles));

이름은 구분자 | 와 줄바꿈을 지우고 길이를 잘라 넣는다. 그 위에는 채울 칸(날짜·요일·끼니), 이미 있는 메뉴, 최근에 먹은 메뉴가 붙고, 가구 인원과 사용자 요청은 맨 아래에 둔다. 시스템 프롬프트는 규칙만 담는다. 목록 밖 번호를 쓰지 말 것, 같은 레시피를 며칠 안에 다시 쓰지 말 것, 사용자 요청은 일반 규칙보다 우선하되 후보와 상차림은 지킬 것.

응답은 글로 받지 않는다. JSON Schema 를 주고 그 모양으로만 답하게 하는 구조화 출력을 쓴다. 처음 붙인 OpenAI 쪽은 response_format 의 json_schema 를 strict 로, 나중에 붙인 Anthropic Claude 쪽은 output_config 의 같은 스키마로 보낸다. 공급자는 ILlmClient 인터페이스 뒤에 숨겼다. 스키마는 한 줄 평(comment)과 칸 목록(items)이고, 끼니와 역할은 enum 으로 묶었다.

모양이 맞다고 내용까지 맞는 건 아니다. 그래서 응답을 받으면 서버가 다시 본다.

if (!need.TryGetValue(slot, out var wanted) || reasons.ContainsKey(slot)) continue;   // 요청 밖 칸·중복 칸
...
if (idNode is not JsonValue v || !v.TryGetValue<int>(out var rid) || !byId.TryGetValue(rid, out var c))
    continue;                                                    // 후보 밖 번호
if (used.Contains(rid)) continue;                                // 같은 날 중복
var dish = wanted.FirstOrDefault(w => !picks.ContainsKey(w)
    && (role == null || w.Role == role) && MealCourse.Fits(w.Role, c.Type));
if (dish == null) continue;                                      // 역할에 안 맞는 종류

요청 밖 칸, 후보에 없는 번호, 같은 날 중복, 국 자리에 들어간 메인 요리는 모두 버린다. 버려진 자리는 그 역할에 맞고 며칠 안에 쓰지 않은 후보로 서버가 채우고, 이유 대신 '자동 보충'을 붙인다. 모델 호출이 아예 실패해도 같은 길로 간다.

맨 처음 장면의 '자동'은 이 안전장치가 한 달 전부를 떠맡은 흔적이었다.

끼니가 아닌 것과 뺄 재료

안전장치가 일하는 걸 보면서 후보부터 다시 손봤다. 보충 규칙으로 채운 미리보기에 초고추장, 데리야끼 소스, 약과가 끼니로 들어가 있었다. 레시피로는 멀쩡하지만 저녁 한 끼는 아니다. 디저트·빵·차/음료·양념/소스/잼·간식·떡·김치/젓갈/장류는 후보 쿼리에서부터 뺐다.

뺄 재료는 처음부터 모델에게 맡기지 않았다. 프롬프트에 적어 두고 지켜 주기를 바라는 건 알레르기가 있는 집에는 너무 약한 약속이다. 서버가 재료 테이블에서 그 단어가 들어간 레시피를 찾아 후보에서 지운다. 뺄 재료는 프롬프트에 들어가지도 않는다.

문제는 사람이 쓰는 말과 레시피에 적힌 말이 다르다는 점이었다. 사용자는 "견과류"라고 쓰는데 레시피 재료에는 "땅콩", "호두", "잣"이라고 적혀 있다. 글자 비교로는 하나도 안 걸린다. 그래서 IngredientExclusion 이 입력 단어를 넓힌다. 견과류는 땅콩·호두·아몬드·잣으로, 해산물은 생선·갑각류·조개류·연체류 전체로, 글루텐은 밀가루·국수·만두·부침가루로, 고기는 돼지·소·닭과 그 부위 이름으로 늘어난다.

넓히는 방향으로만 틀리게 했다. 덜 거르는 것보다 더 거르는 게 낫다. 한 가지 예외는 한 글자 단어다. 게·굴·밀·콩·햄·닭을 레시피 이름에 대고 비교하면 "간단하게", "밀키트" 같은 이름이 줄줄이 걸린다. 한 글자는 재료명에만 비교하고 이름과 주재료 분류에는 두 글자 이상만 쓴다. 미리보기 위에는 실제로 거른 단어 목록을 보여 준다.

'알레르기' 칸은 따로 두지 않았다. 건강 정보를 받는 셈이 되기 때문이다. 싫어서 빼든 못 먹어서 빼든 '뺄 재료' 하나로 충분하다.

AI 식단 미리보기 화면. 위에 식단 전체 한 줄 평과 거른 재료 목록이 있고, 날짜별로 점심과 저녁 메뉴가 체크 칸과 짧은 이유와 함께 나열된다

한 끼는 한 그릇이 아니었다

처음 버전은 한 끼에 레시피 하나였다. 위 화면이 그때 모습이다. 요청 칸에 "국이랑 메인, 반찬 두 가지로"라고 써도 반영될 자리가 없었다. 스키마가 칸마다 번호 하나만 받았으니까.

그래서 끼니마다 상차림을 고르게 했다. 한 그릇(1가지), 국+메인(2가지), 한 상(국+메인+반찬 둘, 밥은 따로 지으니 고르지 않는다). 기본값은 아침·점심 한 그릇, 저녁 한 상이다. 칸 하나가 역할 여러 개로 펼쳐지고, 스키마의 dishes 배열이 역할과 번호 쌍을 받는다.

역할도 모델 말만 믿지 않는다. 레시피의 종류 분류로 서버가 다시 확인한다. 국은 국/탕·찌개, 메인은 메인반찬, 반찬은 밑반찬·샐러드다. 한 그릇은 밑반찬만 아니면 된다. 후보 줄 끝의 '역할' 칸도 이 규칙으로 미리 계산해 넣는다.

후보는 즐겨찾기·내 레시피·자주 쓴 레시피에 점수를 줘 상위 백여 개를 보내는데, 메인반찬이 위쪽을 다 차지해 국과 반찬이 모자랐다. 역할마다 몫을 나눠 따로 뽑은 뒤 합치게 바꿨다. 요청 칸을 비워 두는 사람을 위해 음식 스타일(한식 위주, 한·중·양식 골고루)도 고를 수 있게 했다.

한 달이 출력 한도에 들어가지 않았다

맨 처음 장면으로 돌아간다. 그사이 공급자를 Claude 로 바꾼 뒤였다. 기록을 보니 모델 응답이 출력 길이 상한에서 끊겨 있었다. 끊긴 JSON 은 읽을 수 없으니 호출은 실패로 처리되고, 150개 자리가 전부 보충 규칙으로 갔다.

계산이 안 맞았다. 상차림을 붙일 때 한 달 한 상의 답이 14K 토큰쯤이라 출력 상한을 20,000 으로 올려 두었다. 원인은 보이지 않는 쪽에 있었다. 쓰던 Haiku 5.5 는 생각(thinking)이 기본으로 켜져 있다. 10일 치 답 JSON 은 1.5K 토큰 정도인데, 그 앞에 생각 토큰이 8K 가까이 붙었다. 한 달이면 상한을 넘을 수밖에 없다.

고친 건 두 가지다. 먼저 식단 고르기에서는 생각을 껐다. 후보 목록에서 조건에 맞는 번호를 고르는 일은 깊이 생각할 문제가 아니었다. 10일 치가 57초에서 9초로, 출력 토큰은 5분의 1 가까이로 줄었다. 의외였던 건 다양성이다. 생각을 끈 쪽이 서로 다른 메뉴를 더 많이 골랐다. 다른 기능은 생각을 켠 채로 두도록 요청마다 Think 를 고르게 했다.

그리고 긴 기간은 10일씩 나눠 차례로 부른다.

foreach (var chunk in slots.GroupBy(s => (s.Date.DayNumber - start.DayNumber) / ChunkDays).Select(g => g.ToList()))
{
    try
    {
        var result = await _llm.CompleteJsonAsync(new LlmJsonRequest(SystemPrompt,
            BuildUserPrompt(chunk, courses, roles, candidates, history.Concat(chosen).ToList(), ...),
            "meal_plan", Schema, Think: false), ct);
    }
    catch (Exception ex) when (ex is LlmException or JsonException)
    {
        errors.Add(ex.Message);   // 이 구간만 규칙으로, 나머지는 살린다
    }
}

앞 구간이 고른 메뉴는 다음 구간의 '최근 먹은 메뉴'로 넘겨 경계에서 반복되지 않게 한다. 한 구간이 실패해도 나머지 구간의 결과는 버리지 않고 "일부 기간은 기본 추천으로 채웠어요"라고만 알린다. 사용 기록은 버튼 한 번을 한 건으로 남긴다. 구간마다 남기면 한 번 누른 게 세 번으로 세어진다.

같은 한 달 한 상이 이제 20초 남짓에 돌아온다. '자동'은 150자리 중 여섯 자리 정도만 남았다.

'자동'이라고 적힌 자리

남은 여섯 자리는 대부분 같은 날 중복이거나 역할에 안 맞아 버려진 자리였다. 사용자는 어느 자리가 그런지 알 수 없었다. 미리보기 위에 "N자리는 같은 날 겹치거나 자리에 맞지 않아 기본 추천으로 바꿔 넣었어요"라고 알리고, 그 자리에 '자동' 표시를 달았다. 그리고 버튼 하나를 붙였다. 이 자리만 AI 로 다시 고르기.

다시 고르기는 채울 자리만 보내는 작은 요청이다. 처음에는 앞뒤 며칠 안에 정해 둔 메뉴를 "이 레시피들은 고르지 말 것"이라고 프롬프트에 적었다. 그런데 생각을 끈 모델은 이 목록을 자주 무시했다. 어제 메뉴가 오늘 다시 나왔다. 결국 말로 부탁하는 대신 후보에서 지웠다.

var candidates = (await LoadCandidatesAsync(access, cond, excludeTerms, roles, ct))
    .Where(c => !avoid.Contains(c.Id) && !dishes.Any(d => TooSoon(recent, c.Id, d.Slot.Date)))
    .ToList();

뺄 재료 때와 같은 결론이다. 반드시 지켜야 하는 조건은 프롬프트가 아니라 후보 목록으로 건다. 모델이 볼 수 없는 레시피는 고를 수도 없다. 한 달 한 상에서 남은 '자동' 자리 몇 개가 다시 고르기 한 번으로 모두 사라졌다. 이 요청은 하루 사용 횟수에 넣지 않았다. 새 식단을 짜는 게 아니라 방금 짠 미리보기를 마무리하는 일로 봤다. 비용은 월 예산에는 그대로 잡힌다.

같은 길을 하나 더 열었다. 마음에 안 드는 메뉴의 체크를 풀고 '다시 고르기'를 누르면 그 자리만 바뀐다. 체크한 메뉴는 그대로 두고, 방금 뺀 레시피는 avoid 로 넘겨 후보에서 지운다. 싫다고 뺀 메뉴가 다시 나오면 다시 고르기의 의미가 없다. 처음부터 다시 짜던 '다시 짜기' 버튼은 이름이 헷갈려 '조건 바꾸기'로 바꿨다.

저녁 칸에 메뉴가 열 개

마지막 문제는 담을 때 생겼다. 어떤 달력의 저녁 칸 하나에 메뉴가 열 개 들어가 있었다. 다시 짜서 담기를 여러 번 한 결과였다. '이미 메뉴가 있는 칸은 건너뛰기' 체크를 풀면 바꾸는 게 아니라 쌓였다.

건너뛰기와 바꾸기 두 가지로 나눴다. 바꾸기로 담으면 실제로 담는 칸(날짜+끼니)의 기존 메뉴를 지우고 새 메뉴를 넣는다. 범위는 좁게 잡았다. 미리보기에서 체크를 모두 푼 칸, 고르지 않은 끼니의 메뉴는 그대로 둔다. 지울 칸의 기존 메뉴는 프롬프트의 '이미 있는 메뉴(겹치지 않게 참고)'에서도 뺐다. 곧 지워질 메뉴를 피하느라 좋은 후보를 놓칠 필요는 없다. 미리보기에는 지워질 기존 메뉴 수를 띄우고, 담기 전에 한 번 더 묻는다.

매번 같은 조건을 다시 적는 것도 손이 갔다. 가구 인원, 뺄 재료, 요청, 조리 시간, 끼니, 끼니별 상차림, 음식 스타일을 '이 조건을 기본값으로 저장'으로 남기게 했다. 다음에 창을 열면 그 값으로 채워져 있다.

돌아보면 모델이 맡은 일은 좁다. 후보 안에서 어울리는 조합을 고르고 이유를 다는 것까지다. 무엇을 후보로 넣고 빼는지, 답이 자리에 맞는지, 실패하면 무엇으로 채울지는 서버가 정한다. 그 경계를 지킨 덕에 모델이 한 줄도 답하지 못한 날에도 달력은 채워졌다.

다음 편은 이 기능을 열어 두고 생긴 질문, 버튼 한 번이 얼마인지와 공급자를 바꾸는 자리 이야기다.