Flue 에이전트 훅의 반영 시점은 모델과 프롬프트가 다르다

usePersistentState로 모드 상태를 두고 set_mode 도구로 그 모드를 바꾸게 만든 Flue 에이전트가 있다고 하자. 사용자가 “빠른 모드로 바꿔 줘”라고 보내면 모드가 바뀌었다고 알리는 답까지 빠른 모델이 쓸 것 같지만, 그 응답은 원래 모델이 끝내고 빠른 모델은 다음 메시지부터 답한다. 같은 응답 안에서도 시스템 프롬프트와 도구 목록은 다음 모델 호출부터 바로 바뀐다. Flue 에이전트 훅은 값마다 반영 시점이 다르므로, 상태로 에이전트를 움직이는 코드는 그 값이 언제 반영돼야 하는지부터 정하고 담을 곳을 고른다

set_mode를 부른 응답은 원래 모델로 끝난다

Flue는 에이전트 함수를 실행하는 일을 렌더(render)라고 부른다. React 컴포넌트를 다시 그리는 것과 같은 이름이고, 렌더할 때마다 함수 안의 훅이 처음부터 다시 선언된다. 강의는 이 함수가 사용자가 메시지를 보낼 때마다 다시 실행된다고 설명하는데, 사용자가 보는 단위로는 맞는 말이다. 그 안을 들여다보면 메시지 한 건을 처리하는 동안에도 렌더가 여러 번 일어난다

문서는 렌더 시점을 “모든 모델 호출 직전”으로 적는다. 매 턴마다, 그리고 응답 도중 새 메시지가 합류하는 순간에도 렌더가 일어난다(Agent Hooks API — Rendering and the rules of hooks). 메시지 한 건을 처리하는 동안 모델은 여러 번 호출될 수 있는데, 모델이 도구를 부르면 런타임이 도구를 실행하고 그 결과를 들고 모델을 다시 부르기 때문이다. set_mode 요청을 처리하는 흐름은 다음과 같다

POST 메시지 1건 = 제출(submission) 1건   ← 이 제출 동안 모델은 처음 값(slow)으로 고정
 ├─ 렌더 → 모델 호출 → set_mode 도구 호출 → mode를 fast로 저장
 ├─ 렌더 → 모델 호출 → "빠른 모드로 바꿨다"는 최종 답변
 │         ↑ 시스템 프롬프트의 mode와 도구 목록은 이 렌더에서 이미 fast 기준
 └─ 제출 종료
다음 POST
 └─ 렌더 → 모델 호출 ...                 ← 여기서부터 Flash 모델

두 번째 렌더에서 usePersistentState는 이미 fast를 돌려주므로, 시스템 프롬프트에 끼워 넣은 you are in ${mode} mode는 그 자리에서 바뀐다. useModel도 같은 렌더에서 Flash 모델을 선언하지만 반영되지 않는다. 문서는 모델, 추론 수준, 컴팩션 설정을 제출 단위 값으로 묶고, 처리 도중 바뀐 값은 다음 제출에서 적용된다고 적는다(Models — Changing models mid-conversation). 제출은 메시지 한 건을 접수해서 끝낼 때까지의 처리 단위이고, dispatch(...)에도 메시지마다 모델을 고르는 인자는 없다

그래서 모드 전환을 확인하는 답에서는 속도 차이를 보여 줄 수 없다. 사용자가 전환 직후의 응답 속도로 모드를 판단할 만한 화면이라면, 바뀐 모델은 다음 메시지부터 적용된다는 문구를 함께 보여 주겠다. 이 반영 시점은 문서 기준이고 직접 재지는 않았다. 확인하려면 같은 대화에서 전환 요청과 그다음 메시지가 각각 어느 모델로 호출됐는지 호출 기록을 비교해야 한다

컴팩션 설정도 같은 단위로 묶인다. 컴팩션(compaction)은 대화가 모델의 컨텍스트 창에 가까워지면 오래된 기록을 요약으로 접는 동작으로, Flue가 쓰는 Pi 하네스가 기본으로 수행한다. 예제에서는 요약에 Flash 모델을 지정했다

useModel(mode === "fast"
        ? "cloudflare/@cf/zai-org/glm-5.3-flash"
        : "cloudflare/@cf/zai-org/glm-5.3", {
        compaction: {
            model: "cloudflare/@cf/zai-org/glm-5.3-flash",
        },
    },
);

대화 모델은 모드에 따라 바뀌어도 요약은 항상 Flash가 맡는다. 문서도 요약에는 대화 모델보다 싼 모델을 쓰라고 안내하고, 컴팩션을 시작하는 여유 토큰과 원문으로 남길 최근 분량의 기본값을 함께 적어 둔다(Models — Compaction). 요약처럼 품질보다 비용이 중요한 작업에는 싼 모델을 붙이는 편이 맞다고 본다

훅은 렌더 중에만 부를 수 있다. 에이전트 함수 본문이나 그 안에서 호출한 커스텀 훅 밖, 예를 들어 도구의 run이나 이벤트 훅 콜백 안에서 훅을 부르면 [flue] <hook>() was called outside an agent function. 예외가 난다

값은 누가 언제 읽는지로 담을 곳을 고른다

에이전트가 쓰는 값을 둘 곳은 초기 데이터, 영속 상태, 데이터 파트다. 반영 시점과 읽는 쪽이 서로 다르다

담을 곳모델이 보는가바뀌는 시점남는 기간
useInitialData시스템 프롬프트에 넣으면 본다바뀌지 않는다. 대화를 만들 때 기록대화 전체
usePersistentState시스템 프롬프트에 넣으면 본다쓴 다음 렌더부터대화 전체
useDataWriter보지 않는다쓰는 즉시 화면으로메시지 파트로 기록

모델이 판단에 써야 하는 값은 위의 두 곳에, 사람에게 보여 주기만 할 값은 데이터 파트에 둔다. 위 두 곳 가운데서는 대화 도중 바뀔 일이 있느냐로 고른다

예제의 고객 지원 에이전트는 상담할 고객 이름을 초기 데이터로 받는다

export function CustomerSupport() {
    const {name} = useInitialData<{ name: string }>()
    useModel('cloudflare/@cf/zai-org/glm-5.3-flash');
    return `You are a customer support agent and now you're helping ${name}`;
}

CustomerSupport.initialData = v.object({
    name: v.string(),
});

initialData는 초기 데이터의 모양을 Valibot 스키마로 선언한다. Valibot은 런타임에 값을 검증하는 라이브러리이고, Flue는 도구 입력 검증에도 이것을 쓴다. 문서에 따르면 초기 데이터는 대화를 만드는 요청에서 한 번만 기록되고, 이미 있는 대화에 다시 보낸 값은 무시된다(Agent Hooks — Passing data to the agent). 스키마에 맞지 않는 요청은 아무것도 저장하지 않고 400 invalid_request로 거부된다(Streaming Protocol). 그래서 매 메시지에 함께 보내도 해는 없지만, 스키마를 선언해 두면 이름을 모르는 채로 새 대화를 여는 요청은 막힌다. 이름을 받기 전에 대화부터 여는 화면이라면 스키마의 name을 선택 항목으로 두거나 이름을 먼저 받는 편이 낫다

시스템 프롬프트에 끼워 넣기에도 초기 데이터가 가장 안전하다. 모델 제공자의 프롬프트 캐시는 이전 요청과 앞부분이 같은 입력을 재사용해 토큰 비용을 낮추는데, 문서는 지시문에 동적 데이터를 넣으면 이 캐시가 깨질 수 있다고 경고한다(Agents — Agent Functions). 대화 내내 고정인 이름은 이 문제가 없다. 반면 예제의 시스템 프롬프트에 넣은 you are in ${mode} mode는 모드가 바뀔 때마다 캐시를 깬다. 모드는 사용자가 가끔 바꾸는 값이라 감수할 만하다고 본다

모드는 영속 상태로 두고 도구로 바꾼다

const [mode, setMode] = usePersistentState<"fast" | "slow">("mode", "slow");
useTool({
    name: "set_mode",
    description: "Use when the user wants to set the mode of the agent",
    input: v.object({
        mode: v.picklist(["fast", "slow"]),
    }),
    run: ({data: {mode}}) => {
        console.log("New mode is", mode);
        setMode(mode);
        return { output: `New mode set to ${mode}` };
    },
});

첫 인자 "mode"는 대화 저장소의 키이고 두 번째 인자는 기본값이다. 쓰기는 대화 저장소에 기록되고, 렌더마다 최신 값을 다시 읽는다. 값은 JSON으로 직렬화할 수 있어야 한다(Agent Hooks — Persisted state). 렌더 시점의 값은 스냅숏이므로, 이전 값에서 다음 값을 계산할 때는 setCount((previous) => previous + 1)처럼 함수를 넘긴다

모드를 도구 안에서 바꾸는 구조가 안전한 이유도 문서에 있다. 도구 안의 상태 쓰기는 그 턴의 도구 결과와 함께 확정되고, 도구 도중 프로세스가 죽어 그 턴이 중단으로 정리되면 쓰기도 없던 일이 된다(Durability — Persisted state). 도구 결과와 상태가 함께 확정되거나 함께 버려지므로, 크래시 뒤에 결과는 남았는데 상태만 바뀌지 않은 어긋남을 걱정하지 않아도 된다

진행 상황 문구는 데이터 파트로 보낸다

const writeProgress = useDataWriter("progress", {
    schema: v.object({ stage: v.string() }),
});

// add 도구의 run 안에서
writeProgress({ stage: "Starting..." });
await new Promise((resolve) => setTimeout(resolve, 5000));
writeProgress({ stage: "Finishing..." });

채널 이름이 progress이므로 화면에는 type이 data-progress인 파트로 도착하고, schema는 매 쓰기를 검증한다. 데이터 파트는 모델에 전달되지 않고, 쓰기가 에이전트 함수를 다시 실행하지도 않는다. 데이터 라이터는 조건 없이 선언하고 이름은 하나씩만 쓴다(Agent Hooks — Streaming data to the client). 진행 문구처럼 모델이 볼 필요가 없는 값은 여기 두는 것이 맞다. 반대로 모델이 다음 판단에 써야 하는 중간 결과를 데이터 파트에만 쓰면 모델은 그 값을 모르므로, 그런 값은 도구 출력으로 돌려준다

도구 인자로 넘어온 고객 ID는 믿을 수 없다

useDelivery()는 지금 모델 앞에 놓인 메시지를 돌려준다. 예제에서는 메시지 본문에 /fast가 있으면 Flash 모델을 쓰게 했다

const message = useDelivery();
useModel(message.body.includes("/fast")
    ? "cloudflare/@cf/zai-org/glm-5.3-flash"
    : "cloudflare/@cf/zai-org/glm-5.3",
);

이 값은 커서처럼 움직인다. 응답을 깨운 메시지에서 시작해, 응답 도중 새 메시지가 모델에 닿을 때마다 그 메시지로 옮겨 간다(Agent Hooks API — useDelivery)

조건부 모델은 이 훅이 무엇을 읽는지 보여 주기 좋은 예제이고, 실무에서는 접근 제어에 쓸 일이 더 많다고 본다. 문서는 도구 인자를 모델이 고른 입력으로 보고 권한 경계로 쓰지 말라고 적는다(Tools — Protect access). 주문 조회 도구라면 모델은 어떤 주문을 볼지 고를 수 있어도 쿼리에 쓸 고객은 고를 수 없어야 한다. 문서가 권하는 방법은 신뢰할 수 있는 서버 코드가 호출자를 검증한 뒤 dispatch(...)로 보내는 신호(signal)의 attributes에 고객 ID를 싣고, 도구는 그 값을 useDelivery()로 읽는 것이다. 신호는 사람이 입력한 채팅과 별도로 애플리케이션 코드가 보내는 메시지다. 1편 “Flue 에이전트는 시스템 프롬프트를 반환하는 함수다”에서 짚은, 토큰 검사만으로는 남의 대화 ID로 들어오는 요청을 막지 못하는 문제와 같은 줄기다

조건부 도구는 프롬프트 지시보다 확실하지만 캐시를 깬다

모델이 도구에서 보는 것은 이름, 설명, 입력 스키마뿐이다. 문서는 설명을 모델이 받는 유일한 문서로 보고, 무엇을 하는지와 언제 쓰는지, 무엇을 돌려주는지를 적으라고 한다(Tools — How a tool call works). 다른 도구의 설명을 복사해 둔 채로 두면 모델은 두 도구를 구분할 단서를 잃는다

모델이 넘긴 인자가 입력 스키마에 맞지 않으면 run은 호출되지 않고, 실패가 도구 오류로 모델에 돌아가 모델이 인자를 고쳐 다시 부른다. 반환값은 출력 스키마가 있는지에 따라 규칙이 갈린다. 출력 스키마를 선언한 도구는 그 스키마에 맞는 값을 반드시 돌려줘야 하고, 예제의 add가 이 경우다. 출력 스키마가 없는 도구라면 아무것도 반환하지 않아도 되고, 문자열만 돌려주면 { output: 문자열 }로 취급한다. run 안에서 던진 예외는 에이전트를 멈추지 않고, 모델이 보는 오류 결과가 된다

렌더마다 도구 목록을 처음부터 선언하므로 useTool을 if 안에 두면 조건부 도구가 된다. 강의는 slow 모드일 때만 add를 쓸 수 있게 하는 예를 든다

if (mode === "slow") {
    useTool({ name: "add", /* ... */ });
}

선언되지 않은 도구는 모델이 부를 수 없으니, 시스템 프롬프트에 “이 도구는 쓰지 마”라고 적는 것보다 확실하다. 목록이 바뀌면 런타임이 다음 턴 경계에서 resources 신호로 모델에 알린다(Tools — Conditional tools). set_mode를 붙인 뒤 모델의 추론에 새 도구를 알아챘다는 문구가 나오는 것이 이 신호를 읽은 흔적으로 보인다

대가는 프롬프트 캐시다. 도구 목록이 바뀌면 모델 제공자에게 보내는 도구 배열이 달라져 캐시가 깨지므로, 문서는 자주 바뀌지 않는 상태에 조건을 걸라고 권한다. 모드처럼 사용자가 가끔 바꾸는 상태에 거는 것은 괜찮다고 본다. 하지만 앞의 /fast처럼 메시지 내용으로 갈리는 조건에 도구를 걸면 메시지마다 캐시가 깨질 수 있다. 언제나 필요한 도구라면 조건 없이 두는 편이 캐시에도 유리하다

이벤트 훅의 외부 효과는 두 번 일어날 수 있다

응답의 수명 주기에 끼어드는 이벤트 훅은 네 개다

useAgentStart(({log}) => {
    log.info("Delivery started");
});

useResponseStart(() => ({
    startedAt: new Date().toISOString(),
}));

useResponseFinish(({response}) => ({
    usage: response.usage,
}));

useAgentFinish(({response, log}) => {
    log.info("Agent finished", {
        toolCalls: response.toolCalls.length,
    });
});

useResponseStart와 useResponseFinish가 돌려준 객체는 응답 메시지의 메타데이터에 병합되므로, 프런트에서 message.metadata를 출력하면 startedAt과 토큰 사용량이 함께 보인다. 사용량에는 입력·출력 토큰, 캐시 읽기·쓰기 토큰, 합계, 모델 카탈로그 요율로 추정한 비용이 들어 있다(Observability — Token usage)

네 훅의 실행 시점은 문서에 다음과 같이 적혀 있다(Agent Hooks API)

훅실행 시점성질
useAgentStart입력이 저장된 뒤 모델이 읽기 전, 전달된 메시지마다 한 번async 가능. 던지면 그 제출이 실패한다
useResponseStart응답마다 한 번, 첫 모델 호출 전동기. 반환 객체가 메타데이터가 된다
useResponseFinish마지막 useAgentFinish 순환이 끝나고 출력 쓰기가 모두 반영된 뒤 한 번동기. 반환 객체가 메타데이터가 된다
useAgentFinish응답이 끝나려는 시점마다async 가능. append로 신호를 넣어 응답을 이어 갈 수 있고, 응답당 32회가 상한이다

응답 도중 메시지가 합류하면 useAgentStart는 한 응답 안에서 여러 번 불린다. 그리고 이 콜백들은 최소 한 번(at-least-once) 실행된다. 신호 추가나 상태 쓰기 같은 영속 결과는 원자적으로 확정되지만, 네트워크 호출이나 파일 쓰기 같은 외부 효과는 드물게 두 번 일어날 수 있다. 로그와 메타데이터는 두 번 남아도 문제가 없으니 이벤트 훅에 두기 좋다. 환영 이메일이나 결제 알림처럼 두 번 나가면 안 되는 일을 이벤트 훅에 둔다면, 보낸 기록을 영속 상태에 남기고 그 기록을 확인한 뒤 보내야 한다. 그 구현은 3편 “Flue durable 도구만 크래시 후 재실행된다”에서 다룬다

모델 호출마다 에이전트 함수가 다시 실행된다는 사실만으로는 코드가 언제 효과를 내는지 알 수 없다. 모델 선택은 제출 단위로 묶이고, 시스템 프롬프트와 도구 목록은 다음 모델 호출부터, 데이터 파트는 쓰는 즉시 반영된다. 새 상태를 추가할 때 이 가운데 어느 시점이 필요한지부터 따지면 그 값을 담을 곳도 정해진다


이 시리즈의 글: Flue로 AI 에이전트 만들기

  1. Flue 에이전트는 시스템 프롬프트를 반환하는 함수다
  2. Flue 에이전트 훅의 반영 시점은 모델과 프롬프트가 다르다 (이 글)
  3. Flue durable 도구만 크래시 후 재실행된다
  4. Flue 스킬·MCP·서브에이전트·샌드박스는 컨텍스트에 들어오는 방식이 다르다
  5. Flue 대화 스트림은 offset으로 이어 받는다

출처와 범위

노마드 코더 AX 스쿨 Flue 강의를 듣고 썼다. AI(Claude)와 함께 2026년 11월 1일 기준 공식 문서를 대조하며 썼다.

참고 자료