Dify Chatflow 답변이 마지막에 한꺼번에 나올 때 원인 분리하기

Dify Chatflow 답변이 작업 종료 시 한꺼번에 표시될 때 모델이 스트리밍하지 않는다고 단정하면 안 됩니다. 같은 프롬프트와 모델을 고정하고 상위 첫 token, SSE, agent_log, 사용자용 message 도착 시간을 비교합니다. 실행 가능한 기록 스크립트, Fail Branch와 Answer 변수 순서 대조, 오류 처리를 유지하는 판단 절차를 제공합니다.

목차
Dify Chatflow 답변이 마지막에 한꺼번에 나올 때 원인 분리하기

Dify Chatflow 답변이 마지막에 한꺼번에 표시된다고 해서 상위 모델이 스트리밍하지 않는 것은 아닙니다. provider를 바꾸면 해결된다는 뜻도 아닙니다. 같은 짧은 프롬프트, 같은 모델, 같은 Agent 설정을 유지하고 상위 첫 token, SSE 이벤트, agent_log, 사용자에게 전달되는 message의 네 시간을 비교하세요.

핵심은 “Dify가 streaming을 지원하는가”가 아니라 “어느 계층에서 텍스트가 기다리거나 버퍼링되는가”입니다. 운영 오류 처리를 먼저 없앨 필요는 없습니다. Fail Branch 비활성화는 테스트 복사본에서만 쓰는 대조 조건입니다.

이벤트 패턴으로 의심할 계층 정하기

관찰한 현상먼저 의심할 곳다음 확인
LLM → Answer 대조도 초기 텍스트가 없다모델, provider, gateway, 모델 설정짧은 일반 텍스트 요청과 모델 streaming 지원 확인
workflow_started, node_started 등 모든 이벤트가 늦다Dify 앞단 proxy, gateway, SSE 전송같은 요청을 Dify에 직접 보내 응답 buffering 분리
실행 중 agent_log는 오지만 message가 끝에 몰린다Agent → Answer 텍스트 경로, Fail Branch 대조운영 fallback을 유지하고 복사본에서 시간 비교
다른 변수가 끝난 뒤에야 message가 시작된다Answer 앞부분의 미해결 변수변수 순서를 바꾸거나 여러 Answer로 단계별 전달

Dify Answer 문서는 변수 사용 가능 여부에 따라 내용을 순차 전송하며, 첫 번째 미해결 변수에서 기다린다고 설명합니다. 따라서 상위 노드의 실행 순서가 아니라 Answer에 적힌 변수 순서가 사용자가 먼저 보는 내용을 결정합니다.

한 번에 하나만 바꾸는 네 가지 대조 만들기

기존 Chatflow를 복제해 테스트용으로 사용합니다. 게시 버전, Model ID, temperature, Agent strategy, tools, 최대 반복 횟수, memory, error handling을 기록하세요. 모든 실행에서 같은 짧은 프롬프트를 사용합니다. 예:

스트리밍 응답을 디버깅하기 위한 점검 항목 12개를 번호와 함께 한 문장씩 작성하세요.

시간 차이를 볼 수 있을 만큼 답변이 길어야 하지만 실제 사용자 데이터는 넣지 않습니다.

대조 A, B, C, D를 실행할 때마다 같은 순서를 반복합니다. 해당 회차의 변경 하나만 적용하고, 테스트 복사본의 현재 상태를 게시하고, 실행 라벨과 게시된 workflow 버전을 기록한 뒤, 그 복사본의 API Access에 표시된 Service API Base(전체 경로는 보통 /v1로 끝남)와 해당 App API Key를 사용합니다. 원본 앱의 키를 재사용하지 마세요. workflow_id를 생략하면 Dify는 최신 게시 버전을 실행하므로, 게시 전에 호출하면 여러 회차가 같은 이전 버전을 측정할 수 있습니다.

대조 A: LLM → Answer만 사용

테스트 복사본에서 같은 모델의 LLM 출력을 Agent와 tools 없이 Answer로 바로 보냅니다. Dify는 LLM 노드가 기본적으로 스트리밍 출력을 지원한다고 문서화합니다. 여기서도 message가 끝에만 오면 아직 Agent를 원인으로 보지 마세요. provider, OpenAI-compatible gateway, reverse proxy, Answer 변수 순서를 먼저 확인합니다.

대조 B: 현재 Agent와 Fail Branch 유지

실제 Agent 경로와 운영 fallback을 복원해 기준선으로 삼습니다. 화면을 빠르게 보이게 하려고 복구 경로를 삭제하지 마세요. Fail Branch는 실패 후 로그, 알림, 다른 방식, backup service 같은 별도 흐름을 실행하기 위한 기능입니다.

대조 C: 테스트 복사본에서만 Fail Branch 끄기

현재 Agent 노드가 같은 error handling 옵션을 제공할 때만 수행합니다. 복사본의 오류 처리를 기본값으로 돌리고 모델, 프롬프트, tools와 다른 설정은 그대로 둡니다. 이 결과는 현재 환경에서 분기와 지연이 함께 나타나는지만 말해 줍니다. 운영에서 fallback을 제거해도 안전하다는 뜻은 아닙니다.

Dify 1.17.1, langgenius/agent 0.0.33, graphon 0.7.0 환경의 사용자 보고에서는 Fail Branch 사용 시 805개의 message가 29.819초부터 30.035초 사이, 즉 0.216초에 집중됐습니다. 기본 오류 처리 대조에서는 866개가 3.397초부터 40.991초까지 분산됐고, 같은 실행 중 agent_log는 계속 도착했습니다. 이는 특정 환경의 보고이며 모든 Dify 버전의 일반 결론도, 확인된 수정 버전도 아닙니다.

대조 D: Answer 변수 순서만 바꾸기

먼저 Answer가 Agent의 text만 참조하게 하고 다른 변수를 하나씩 다시 추가합니다. 늦게 끝나는 변수가 Agent 텍스트보다 앞에 있으면 Agent가 생성 중이어도 Answer가 기다릴 수 있습니다. 스트리밍 텍스트 변수를 앞으로 옮기거나 공식 지원되는 여러 Answer 노드로 단계별 내용을 전달합니다.

최소 API 호출로 실제 도착 시간 저장하기

브라우저 애니메이션만 보지 말고 게시된 Chatflow API를 호출합니다. Dify streaming 가이드에 따르면 response_mode: "streaming"은 SSE를 반환합니다. Chatflow의 사용자 답변은 message 이벤트를 순서대로 이어 붙인 결과입니다. agent_log는 Agent 단계의 정보성 로그이므로 이벤트 채널이 살아 있다는 증거일 뿐, 답변 텍스트가 사용자에게 전달됐다는 증거는 아닙니다.

매 실행마다 테스트 복사본의 API Access에서 Service API Base와 App API Key를 가져옵니다. Base는 보통 /v1을 포함한 전체 값을 입력하고, 스크립트가 /chat-messages를 붙입니다. 버전 고정이 가능하면 해당 회차의 게시된 workflow version ID를 DIFY_WORKFLOW_ID에 넣습니다. 의도적으로 최신 게시 버전을 쓸 때만 비워 둡니다. 어느 경우든 먼저 게시하고 버전을 DIFY_RUN_LABEL에 기록합니다.

DIFY_TEST_INPUTS_JSON에 {}를 넣을 수 있는 경우는 필수 사용자 입력이 없을 때뿐입니다. Start/User Input에 필수 필드가 있으면 앱의 정확한 파라미터 이름과 타입에 맞춘 같은 비식별 JSON 값을 네 번 모두 전달합니다.

Bash에서 App API Key를 화면에 표시하지 않고 읽고 실행 값을 입력합니다.

unset DIFY_TARGET_NODE_ID DIFY_FALLBACK_NODE_ID DIFY_EXPECTED_FALLBACK
read -r DIFY_API_BASE && export DIFY_API_BASE
read -rs DIFY_API_KEY && export DIFY_API_KEY
read -r DIFY_TEST_QUERY && export DIFY_TEST_QUERY
read -r DIFY_RUN_LABEL && export DIFY_RUN_LABEL
read -r DIFY_WORKFLOW_ID && export DIFY_WORKFLOW_ID
read -r DIFY_TEST_INPUTS_JSON && export DIFY_TEST_INPUTS_JSON
python3 capture_dify_sse.py

다음을 capture_dify_sse.py로 저장합니다.

import json
import os
import sys
import time
from collections import defaultdict
from http.client import IncompleteRead
from pathlib import Path
from urllib import error, request


def redacted(value):
    return None if value is None else f"<redacted:{len(str(value))} chars>"


def load_inputs():
    raw = os.environ.get("DIFY_TEST_INPUTS_JSON", "{}").strip() or "{}"
    try:
        value = json.loads(raw)
    except json.JSONDecodeError as exc:
        raise SystemExit(f"DIFY_TEST_INPUTS_JSON must be valid JSON: {exc.msg}") from exc
    if not isinstance(value, dict):
        raise SystemExit("DIFY_TEST_INPUTS_JSON must decode to a JSON object")
    return value


api_base = os.environ["DIFY_API_BASE"].rstrip("/")
api_key = os.environ["DIFY_API_KEY"]
test_query = os.environ["DIFY_TEST_QUERY"]
raw_label = os.environ.get("DIFY_RUN_LABEL", "unnamed-run").strip() or "unnamed-run"
run_label = "".join(
    char if char.isalnum() or char in "-_" else "_" for char in raw_label
)[:80] or "unnamed-run"
workflow_id = os.environ.get("DIFY_WORKFLOW_ID", "").strip()
target_node_id = os.environ.get("DIFY_TARGET_NODE_ID", "").strip()
fallback_node_id = os.environ.get("DIFY_FALLBACK_NODE_ID", "").strip()
expected_fallback = os.environ.get("DIFY_EXPECTED_FALLBACK", "")

body = {
    "inputs": load_inputs(),
    "query": test_query,
    "response_mode": "streaming",
    "user": "streaming-probe",
}
if workflow_id:
    body["workflow_id"] = workflow_id

req = request.Request(
    f"{api_base}/chat-messages",
    data=json.dumps(body).encode(),
    headers={
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
    },
    method="POST",
)

started = time.perf_counter()
stats = defaultdict(list)
answer_parts = []
message_end_seen = False
workflow_finished_seen = False
workflow_status = None
terminal_event = None
fatal_reason = None
target_node_failed = None if not target_node_id else False
fallback_node_succeeded = None if not fallback_node_id else False
top_error = {"status": None, "code": None, "message_redacted": None}

log_path = Path(f"{run_label}-events.jsonl")
summary_path = Path(f"{run_label}-summary.json")

with log_path.open("w", encoding="utf-8") as log_file:
    try:
        with request.urlopen(req, timeout=180) as response:
            event_name = None
            for line_no, raw_line in enumerate(response, 1):
                line = raw_line.decode("utf-8", errors="replace").strip()
                if line.startswith("event:"):
                    event_name = line.split(":", 1)[1].strip()
                    continue
                if not line.startswith("data:"):
                    continue

                elapsed = round(time.perf_counter() - started, 3)
                try:
                    event = json.loads(line.split(":", 1)[1].strip())
                except json.JSONDecodeError:
                    fatal_reason = f"json_parse_error_at_stream_line_{line_no}"
                    record = {
                        "elapsed_s": elapsed,
                        "event": "parse_error",
                        "detail": "<redacted:invalid JSON payload>",
                    }
                    log_file.write(json.dumps(record, ensure_ascii=False) + "\n")
                    log_file.flush()
                    stats["parse_error"].append(elapsed)
                    print(f"{elapsed:8.3f}s parse_error")
                    break

                kind = event.get("event") or event_name or "unknown"
                event_name = None
                data = event.get("data") if isinstance(event.get("data"), dict) else {}
                answer = event.get("answer") if isinstance(event.get("answer"), str) else ""
                answer_parts.append(answer)

                node_id = data.get("node_id")
                data_status = data.get("status")
                error_status = event.get("status") if kind == "error" else None
                error_code = event.get("code") if kind == "error" else None
                error_message = redacted(event.get("message")) if kind == "error" else None
                record = {
                    "elapsed_s": elapsed,
                    "event": kind,
                    "answer_chars": len(answer),
                    "task_id": event.get("task_id"),
                    "workflow_run_id": event.get("workflow_run_id"),
                    "node_id": node_id,
                    "node_title": data.get("title"),
                    "data_status": data_status,
                    "error_status": error_status,
                    "error_code": error_code,
                    "error_message_redacted": error_message,
                }
                log_file.write(json.dumps(record, ensure_ascii=False) + "\n")
                log_file.flush()
                stats[kind].append(elapsed)

                if kind == "message_end":
                    message_end_seen = True
                elif kind == "workflow_finished":
                    workflow_finished_seen = True
                    workflow_status = data_status
                    terminal_event = kind
                elif kind == "workflow_paused":
                    workflow_status = "paused"
                    terminal_event = kind
                elif kind == "error":
                    top_error = {
                        "status": error_status,
                        "code": error_code,
                        "message_redacted": error_message,
                    }
                    terminal_event = kind

                if kind == "node_finished" and node_id == target_node_id:
                    target_node_failed = data_status in {"failed", "exception"}
                if kind == "node_finished" and node_id == fallback_node_id:
                    fallback_node_succeeded = data_status == "succeeded"

                print(
                    f"{elapsed:8.3f}s {kind:<22} answer_chars={len(answer)} "
                    f"data_status={data_status} error_status={error_status} "
                    f"error_code={error_code} error_message={error_message}"
                )
    except KeyboardInterrupt:
        fatal_reason = "interrupted_by_user"
    except error.HTTPError as exc:
        fatal_reason = f"http_error_{exc.code}"
        top_error = {
            "status": exc.code,
            "code": "http_error",
            "message_redacted": redacted(exc.reason),
        }
    except error.URLError as exc:
        fatal_reason = "connection_error"
        top_error = {
            "status": None,
            "code": "connection_error",
            "message_redacted": redacted(exc.reason),
        }
    except (IncompleteRead, TimeoutError, OSError) as exc:
        fatal_reason = type(exc).__name__
        top_error = {
            "status": None,
            "code": type(exc).__name__,
            "message_redacted": redacted(exc),
        }

if fatal_reason:
    run_outcome, incomplete_reason = "incomplete", fatal_reason
elif workflow_status == "succeeded" and message_end_seen and workflow_finished_seen:
    run_outcome, incomplete_reason = "succeeded", None
elif terminal_event == "workflow_paused":
    run_outcome, incomplete_reason = "paused", None
elif workflow_status in {"failed", "partial-succeeded", "stopped"}:
    run_outcome, incomplete_reason = workflow_status, None
elif terminal_event == "error":
    run_outcome, incomplete_reason = "failed", None
elif workflow_finished_seen:
    run_outcome = "unknown_terminal_status"
    incomplete_reason = "workflow_finished_without_recognized_status"
else:
    run_outcome = "incomplete"
    incomplete_reason = "stream_ended_without_terminal_event"

expected_fallback_seen = (
    None if not expected_fallback else expected_fallback in "".join(answer_parts)
)
complete_for_timing_acceptance = run_outcome == "succeeded"
failure_validation_requested = any(
    (target_node_id, fallback_node_id, expected_fallback)
)
failure_validation_configured = all(
    (target_node_id, fallback_node_id, expected_fallback)
)
failure_validation_passed = (
    None
    if not failure_validation_requested
    else failure_validation_configured
    and run_outcome in {"succeeded", "partial-succeeded"}
    and all(
        value is not False
        for value in (
            target_node_failed,
            fallback_node_succeeded,
            expected_fallback_seen,
        )
    )
)
acceptance_checks_passed = (
    failure_validation_passed
    if failure_validation_requested
    else complete_for_timing_acceptance
)

summary = {
    "run_label": run_label,
    "requested_workflow_id": workflow_id or None,
    "version_selection": "pinned-published-version" if workflow_id else "latest-published-version",
    "run_outcome": run_outcome,
    "workflow_status": workflow_status,
    "terminal_event": terminal_event,
    "message_end_seen": message_end_seen,
    "workflow_finished_seen": workflow_finished_seen,
    "incomplete_reason": incomplete_reason,
    "top_level_error": top_error,
    "target_node_failed": target_node_failed,
    "fallback_node_succeeded": fallback_node_succeeded,
    "expected_fallback_seen": expected_fallback_seen,
    "complete_for_timing_acceptance": complete_for_timing_acceptance,
    "failure_validation_requested": failure_validation_requested,
    "failure_validation_configured": failure_validation_configured,
    "failure_validation_passed": failure_validation_passed,
    "acceptance_checks_passed": acceptance_checks_passed,
    "event_timings": {
        kind: {
            "count": len(times),
            "first_s": times[0],
            "last_s": times[-1],
            "span_s": round(times[-1] - times[0], 3),
        }
        for kind, times in sorted(stats.items())
    },
}
summary_path.write_text(
    json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(json.dumps(summary, ensure_ascii=False, indent=2))
if not acceptance_checks_passed:
    sys.exit(2)

스크립트는 답변 본문을 디스크에 저장하지 않습니다. 시간, node ID/title, data.status, 텍스트 길이와 함께 error 이벤트의 최상위 status, code를 기록하고 message는 문자 수 마커로 가립니다. succeeded, failed, partial-succeeded, stopped, paused, 최상위 error, 종단 이벤트가 없는 stream을 구분합니다.

파일명은 DIFY_RUN_LABEL을 사용합니다. 연결이 끊기거나 SSE JSON 파싱에 실패해도 이미 수집한 시간은 저장하지만 run_outcome은 incomplete, complete_for_timing_acceptance는 false가 됩니다. 이 span은 진단에만 쓸 수 있습니다. 정상 대조는 message_end와 workflow_finished.data.status=succeeded를 모두 확인해야 시간 검증을 통과합니다. HTTP/1.1 chunked SSE가 잘리면서 발생하는 http.client.IncompleteRead도 처리합니다. 수신한 이벤트의 시간은 summary에 남고 acceptance_checks_passed=false가 되며, 예외에 포함된 응답 본문 조각은 출력하지 않습니다.

네 가지 시간만 비교하기

  1. 상위 첫 token. provider나 자체 gateway에 first-token 지표가 있으면 기록합니다. 없으면 LLM → Answer 대조로 상위 경로를 실용적으로 분리합니다.
  2. 첫 SSE data 이벤트. 이른 workflow_started는 실행이 수락됐다는 뜻이지 답변이 순차 전달된다는 뜻은 아닙니다.
  3. 첫 agent_log. 로그가 일찍 오면 Agent 단계 이벤트는 연결을 통과합니다. 로그는 오는데 텍스트가 기다리면 연결 전체가 하나의 객체로 버퍼링될 가능성은 낮아집니다.
  4. 첫 번째와 마지막 message. 사용자 경험을 결정하는 시간입니다. first_s, last_s, span_s를 비교하세요. 많은 이벤트가 아주 짧은 구간에 몰리면 마지막 일괄 표시를 수치로 확인할 수 있습니다.

정상 Chatflow는 message_end 다음에 workflow_finished를 보내고 그 data.status가 succeeded여야 합니다. 공식 SSE 계약에 나열된 workflow_finished.data.status는 succeeded, failed, partial-succeeded, stopped이며 각각 구분해 기록합니다. 사람의 입력을 기다리는 일시 중지에서는 대신 human_input_required 다음에 별도의 workflow_paused 이벤트가 오고, 스크립트는 이 이벤트를 근거로 run_outcome=paused를 기록합니다. 실행 기록의 상태 열거형을 workflow_finished 이벤트의 계약으로 가져오면 안 됩니다. error 이벤트의 상태, 오류 유형, 메시지는 최상위 필드이며 스크립트는 status, code, 가린 message를 표시합니다. 종단 이벤트 없이 연결이 끝나거나 전송/JSON 파싱이 중단되면 summary는 incomplete입니다. 일부 message가 기록됐더라도 complete_for_timing_acceptance가 true가 아니면 그 span으로 개선을 주장할 수 없습니다.

결과에 맞는 변경만 선택하기

LLM 대조도 기다린다: 상위 계층부터 수정

Agent로 돌아가기 전에 Model ID, endpoint, provider streaming, 중간 gateway를 확인합니다. BetterToken을 선택 가능한 OpenAI-compatible 모델 API 기준으로 쓸 때 Dify 설정 문서는 먼저 tools 없는 짧은 일반 텍스트 요청을 확인하고, 선택한 모델과 endpoint가 지원할 때만 tool calls를 켜도록 안내합니다. 상위 모델 계층을 분리하는 데는 도움이 되지만 Dify 노드의 buffering이 없다는 보장은 아닙니다.

모든 이벤트가 늦다: 전송 경로 확인

CDN, reverse proxy, 추가 API gateway를 줄이고 같은 요청을 반복합니다. Dify 직접 연결에서는 순차적으로 오는데 특정 중간 계층을 복원한 뒤 묶인다면 그 계층의 SSE 전달과 response buffering을 확인합니다. 동시에 모델까지 바꾸면 원인을 알 수 없습니다.

Fail Branch에서만 message가 몰린다

결론을 현재 Dify 버전, Agent plugin, workflow 설정으로 제한합니다. 운영 fallback은 기본적으로 유지하세요. 실시간 전달이 더 중요해도 다른 복구 경로를 설계하고 검증한 뒤 전환해야 합니다. 한 번의 대조로 알림, backup 호출, error log를 삭제하지 마세요.

Dify 또는 Agent 플러그인 업그레이드 전에는 완전한 정상 실행 하나와 통제된 실패 검증 하나를 저장합니다. 업그레이드 후 테스트 복사본을 게시하고 같은 prompt, model, 비식별 inputs, 버전 기록 방식으로 반복합니다. 실패 타임라인만 저장했다고 완료가 아닙니다. 대상 node가 실제로 실패하고, 대응하는 Fail Branch와 fallback Answer가 실행되며, 사용자가 비식별 fallback을 받아야 합니다.

Answer 재정렬로 streaming이 돌아온다

상위 model은 계속 streaming 중이었지만 Answer가 앞선 변수를 기다렸을 수 있습니다. 새 순서 또는 단계별 Answer를 유지하고 아래 실패 검증을 수행합니다. error_type과 error_message는 분기와 내부 로그에 사용할 수 있지만, 사용자용 Answer에는 원문 오류 상세 대신 비식별 문구를 사용합니다.

통제된 실패는 테스트 복사본에서만 검증하기

실제 사용자 데이터를 쓰지 않고 반복 가능하며, 검증 대상 node 자체가 node_finished에서 자신의 오류를 나타내는 기존 조건을 우선합니다. Fail Branch로 처리된 오류는 data.status=exception일 수 있으므로 failed를 필수로 요구하면 안 됩니다. 도구 내부 오류, agent_log, model 로그만으로는 Agent node 실패를 증명할 수 없습니다.

해당 버전 소스에서 graphon 0.7.0은 FAIL_BRANCH로 처리한 실패를 상태가 exception인 NodeRunExceptionEvent로 변환합니다. Dify 1.17.1은 이를 node_finished.data.status=exception으로 출력하며, 처리되지 않은 노드 실패의 상태는 failed입니다. 스크립트의 target_node_failed는 exception 또는 failed를 대상 노드 자체의 오류로 판정할 뿐, workflow 전체 실패나 fallback 성공을 뜻하지 않습니다. 검증에는 여전히 해당 fallback 노드의 성공, 사용자에게 보이는 민감 정보 없는 fallback 응답, 아래에 나열된 허용 종단 결과가 필요합니다.

적절한 조건이 없으면 테스트 복사본의 진단 경로에 HTTP Request node를 임시로 추가하고 URL을 https://fail-branch-check.invalid/, timeout을 짧게 설정합니다. .invalid는 반드시 유효하지 않은 이름을 위해 예약돼 있습니다. 이 node의 오류 처리를 Fail Branch로 설정하고 주황색 branch를 검증할 fallback Answer에 연결합니다. 이는 임시 HTTP node의 Fail Branch/Answer 연결을 검증하며, Agent 자체가 검증 대상일 때 Agent-level failure를 대신하지는 않습니다.

  1. 임시 변경 후 테스트 복사본을 게시합니다. 게시 버전, 대상 node ID, fallback Answer node ID, fallback에만 나타나는 짧고 비민감한 문구를 기록합니다.
  2. 복사본의 Base/Key, 같은 비식별 inputs와 그 게시 버전을 사용합니다. 세 검증 값을 모두 설정하고 같은 스크립트를 실행합니다.
read -r DIFY_TARGET_NODE_ID && export DIFY_TARGET_NODE_ID
read -r DIFY_FALLBACK_NODE_ID && export DIFY_FALLBACK_NODE_ID
read -r DIFY_EXPECTED_FALLBACK && export DIFY_EXPECTED_FALLBACK
python3 capture_dify_sse.py
  1. Summary에 target_node_failed: true, fallback_node_succeeded: true, expected_fallback_seen: true, failure_validation_passed: true가 함께 있어야 합니다. Workflow는 succeeded 또는 partial-succeeded일 수 있지만 failed, stopped, paused, incomplete이면 안 됩니다.
  2. 임시 failure node를 삭제하거나 원래 통제 조건, 연결, 설정을 복원하고 다시 게시합니다. 세 검증 변수를 지운 뒤 정상 요청을 다시 실행합니다. 복원 실행에서 complete_for_timing_acceptance: true를 확인한 뒤 message의 첫/마지막 시간을 다시 비교합니다.

streaming과 fallback이 모두 통과해야 완료

첫 chunk가 빨라졌다는 이유만으로 완료하지 마세요. 두 summary를 보관합니다. 하나는 생성 중 message가 분산되어 도착한 완전한 정상 요청이고, 다른 하나는 대상 node 실패, 대응 Fail Branch/fallback Answer 실행, 사용자에게 비식별 fallback 표시를 증명하는 통제 실패입니다. 도구 오류나 Agent 로그만 있거나, 대상 node 실패가 없거나, 사용자 표시 fallback이 없거나, 변경 복원 뒤 정상 재실행이 없으면 검수를 통과하지 못합니다.

LLM 워크플로를 최적화할 준비가 되셨나요?

하나의 API로 모델을 연결하고 키와 AI 비용을 관리하세요.

무료로 시작하기