[fix] solution/backend: 카톡 5초 벽을 콜백으로 넘는다
실사용 첫날 "시설 편의에서 바비큐 이용 문구 빼줘" 가 타임아웃으로 끝났다.
★ 작은 표본으로 잰 수치를 상한 근거로 삼은 것이 틀렸다. 개발 중 잰 1.3~2.4초는
업종 필드 두 개짜리 장난감 프롬프트였고, 진짜 요청에는 필드 43개 + fact 수십 개가
실린다. "여유가 있다" 고 적어 둔 판단이 하루 만에 깨졌다.
- userRequest.callbackUrl 이 오면 {"useCallback": true} 로 즉답하고 백그라운드에서
답을 만든 뒤 그 주소로 POST. 콜백 주소는 1분·1회라 재시도하지 않는다 —
두 번째 POST 는 거절되고 사장님에게는 이미 "확인하고 있어요" 가 가 있다
- 콜백이 꺼져 있으면 예전처럼 동기, 상한만 4.0 → 4.5 (카카오가 5초에 끊는다)
★ 오픈빌더 스킬 설정에서 '콜백 사용' 을 켜야 열린다. 안 켜면 callbackUrl 이 안 와서
조용히 예전 경로로만 돈다.
test_kakao_webhook.py 24 passed(콜백 3건 추가)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1300652b09
commit
f500210cd8
@ -227,12 +227,28 @@ URL 만 알면 누구나 이 엔드포인트를 때릴 수 있고, `userRequest.
|
||||
★ **바로가기 라벨과 '예' 로 읽는 말이 같아야 한다**(`CONFIRM_LABEL` 등 상수). 어긋나면
|
||||
눌러도 안 먹고, 사장님은 버튼이 고장난 줄 안다.
|
||||
|
||||
## 5초 벽
|
||||
## 5초 벽 — 콜백으로 넘는다
|
||||
|
||||
`DEADLINE_SEC = 4.0`. 넘기면 카카오가 연결을 끊어 **말없이 실패하는 봇**이 되므로,
|
||||
안내 문구로 끊는다. 도구 선택은 실측 1.3~2.4초라 여유가 있고, 무거운 잡(BUILD)은 큐에 넣고
|
||||
즉답하는 구조라 여기 걸리지 않는다.
|
||||
★ 콜백으로 나중에 미는 길은 아직 없다 — 오픈빌더 지원 여부를 콘솔에서 확인해야 한다.
|
||||
오픈빌더의 스킬 타임아웃은 **5초**다. 넘기면 카카오가 끊어 **말없이 실패하는 봇**이 된다.
|
||||
|
||||
★ **실측(2026-09-22): 실제 프롬프트는 4초를 넘겼다.** 개발 중 잰 1.3~2.4초는 항목 두 개짜리
|
||||
장난감 프롬프트였고, 진짜는 업종 필드 43개 + fact 수십 개가 실린다. 작은 표본으로 잰 수치를
|
||||
상한 근거로 삼으면 이렇게 틀린다.
|
||||
|
||||
→ 오픈빌더 스킬 설정에서 **콜백 사용**을 켜면 요청에 `userRequest.callbackUrl` 이 실려 온다.
|
||||
|
||||
```
|
||||
카카오 → 우리 발화 + callbackUrl
|
||||
우리 → 카카오 {"version":"2.0","useCallback":true,"data":{"text":"확인하고 있어요…"}} (즉답)
|
||||
… 백그라운드에서 답을 만든다 (상한 45초)
|
||||
우리 → 카카오 POST callbackUrl {"version":"2.0","template":{…}} (완성분)
|
||||
```
|
||||
|
||||
★ 콜백 주소는 **1분 · 1회**만 유효하다. 전송에 실패해도 **재시도하지 않는다** — 두 번째 POST 는
|
||||
어차피 거절되고, 사장님에게는 이미 "확인하고 있어요" 가 가 있다.
|
||||
|
||||
★ 콜백이 꺼져 있으면 예전처럼 동기로 답하되 `DEADLINE_SEC = 4.5` 로 끊는다.
|
||||
무거운 잡(BUILD)은 큐에 넣고 즉답하는 구조라 어느 쪽에서도 걸리지 않는다.
|
||||
|
||||
★ 어떤 실패도 **HTTP 200 + 안내 문구**로 답한다. 메신저에서는 500 도 침묵으로 보인다.
|
||||
|
||||
|
||||
@ -1,5 +1,26 @@
|
||||
# 개발 일지
|
||||
|
||||
## 2026-09-22 — 카톡 5초 벽을 콜백으로 넘는다
|
||||
|
||||
실제 카톡에서 "시설 편의에서 바비큐 이용 문구 빼줘" 가 **"확인하는 데 시간이 조금 걸리네요"**
|
||||
로 끝났다. 타임아웃이었다.
|
||||
|
||||
★ **작은 표본으로 잰 수치를 상한 근거로 삼은 것이 틀렸다.** 개발 중 잰 1.3~2.4초는 업종 필드
|
||||
두 개짜리 장난감 프롬프트였고, 진짜 요청에는 필드 43개 + fact 수십 개가 실린다.
|
||||
"여유가 있다" 고 적어 둔 판단이 실사용 첫날에 깨졌다.
|
||||
|
||||
**고친 방법** — 오픈빌더 콜백(스킬 타임아웃 5초, 콜백 주소 1분·1회):
|
||||
`userRequest.callbackUrl` 이 실려 오면 `{"useCallback": true}` 로 **즉답**하고, 백그라운드에서
|
||||
답을 만든 뒤 그 주소로 따로 POST 한다. 콜백이 꺼져 있으면 예전처럼 동기(4.5초 상한).
|
||||
|
||||
★ 콜백 전송 실패는 **재시도하지 않는다** — 1회용 주소라 두 번째 POST 는 거절되고, 사장님에게는
|
||||
이미 "확인하고 있어요" 가 가 있다.
|
||||
|
||||
★ 오픈빌더 스킬 설정에서 **콜백 사용을 켜야** 이 경로가 열린다. 안 켜면 코드가 있어도
|
||||
`callbackUrl` 이 안 와서 동기 경로로만 돈다 — 조용히 예전처럼 동작한다.
|
||||
|
||||
**검증** — `test_kakao_webhook.py` 24 passed(콜백 3건 추가: 즉답 형식·콜백 전송·전송 실패).
|
||||
|
||||
## 2026-09-22 — 카톡 대화에 홈페이지 목록·가게 고르기
|
||||
|
||||
실제로 붙여 보니 빠진 것이 드러났다(사장님 지적): 연결은 됐는데 **어느 홈페이지를 다루는
|
||||
|
||||
@ -9,15 +9,21 @@
|
||||
시크릿이 없으면 **엔드포인트 자체를 띄우지 않는다(404)** — 반쯤 열린 상태를 만들지 않는 것은
|
||||
Threads 연결과 같은 규칙이다.
|
||||
|
||||
★ 5초 벽: 오픈빌더는 스킬 서버 응답을 오래 기다리지 않는다. 넘기면 카카오가 연결을 끊고,
|
||||
사장님에게는 **말없이 실패하는 봇**이 된다. 무거운 잡(BUILD)은 이미 큐에 넣고 즉답하는
|
||||
구조라 여기 걸리지 않지만, 상한은 명시해 둔다.
|
||||
★ 5초 벽: 오픈빌더의 스킬 타임아웃은 **5초**다. 넘기면 카카오가 끊어 사장님에게는
|
||||
**말없이 실패하는 봇**이 된다.
|
||||
→ 오픈빌더 스킬 설정에서 **콜백 사용**을 켜면 요청에 `userRequest.callbackUrl` 이 실려 온다.
|
||||
그때는 `{"useCallback": true}` 로 **즉답**하고, 답을 다 만든 뒤 그 주소로 따로 보낸다.
|
||||
콜백 주소는 **1분 · 1회**만 유효하다.
|
||||
→ 콜백이 꺼져 있으면 예전처럼 동기로 답하되 `DEADLINE_SEC` 로 끊는다. 실측(2026-09-22):
|
||||
필드 43개 + fact 수십 개가 실린 실제 프롬프트는 4초를 넘겼다 — 개발 중 재본
|
||||
1.3~2.4초는 항목 두 개짜리 장난감 프롬프트였다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
import httpx
|
||||
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request
|
||||
|
||||
from common.logger import LOG
|
||||
from config import agent_config as config
|
||||
@ -25,12 +31,15 @@ from services.agent import channel
|
||||
|
||||
router = APIRouter(prefix="/v1/agent/kakao", tags=["Agent"])
|
||||
|
||||
# 도구 선택 1콜이 실측 1.3~2.4초다. 4초를 넘기면 답을 포기하고 안내 문구로 끊는다 —
|
||||
# 콜백이 꺼져 있을 때만 쓰는 상한. 카카오가 5초에 끊으므로 그보다 살짝 앞에서 우리가 끊는다 —
|
||||
# 침묵보다 "잠시 뒤 다시" 가 낫다.
|
||||
DEADLINE_SEC = 4.0
|
||||
DEADLINE_SEC = 4.5
|
||||
# 콜백이 켜져 있을 때의 상한. 콜백 주소가 1분간 유효하므로 그 안에서 넉넉히 잡는다.
|
||||
CALLBACK_DEADLINE_SEC = 45.0
|
||||
|
||||
_TIMEOUT_TEXT = "확인하는 데 시간이 조금 걸리네요. 잠시 뒤 다시 말씀해 주세요."
|
||||
_ERROR_TEXT = "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요."
|
||||
_WAIT_TEXT = "확인하고 있어요. 잠시만 기다려 주세요."
|
||||
|
||||
|
||||
def _reply(text: str, quick_replies=None) -> dict:
|
||||
@ -62,7 +71,35 @@ def _authorize(secret_in_path: str | None, header_secret: str | None, body: dict
|
||||
raise HTTPException(404)
|
||||
|
||||
|
||||
async def _handle(body: dict) -> dict:
|
||||
async def _answer(utterance: str, speaker: str, deadline: float) -> dict:
|
||||
"""대화 한 턴을 SkillResponse 로. 어떤 실패도 문구로 바꾼다."""
|
||||
try:
|
||||
answer = await asyncio.wait_for(channel.handle(utterance, speaker), timeout=deadline)
|
||||
except asyncio.TimeoutError:
|
||||
LOG.w("[agent/kakao] 응답 시간 초과 — 안내로 끊음")
|
||||
return _reply(_TIMEOUT_TEXT)
|
||||
except Exception as ex: # noqa: BLE001 — 메신저에서는 500 도 침묵으로 보인다
|
||||
LOG.w(f"[agent/kakao] 처리 실패: {type(ex).__name__}")
|
||||
return _reply(_ERROR_TEXT)
|
||||
return _reply(answer["text"], answer.get("quick_replies"))
|
||||
|
||||
|
||||
async def _push(callback_url: str, utterance: str, speaker: str) -> None:
|
||||
"""답을 다 만든 뒤 콜백 주소로 보낸다.
|
||||
|
||||
★ 주소는 1분 · 1회만 유효하다. 실패해도 재시도하지 않는다 — 두 번째 POST 는 어차피
|
||||
거절되고, 사장님에게는 이미 "확인하고 있어요" 가 가 있다."""
|
||||
payload = await _answer(utterance, speaker, CALLBACK_DEADLINE_SEC)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
res = await client.post(callback_url, json=payload)
|
||||
if res.status_code >= 400:
|
||||
LOG.w(f"[agent/kakao] 콜백 전송 실패: {res.status_code}")
|
||||
except Exception as ex: # noqa: BLE001
|
||||
LOG.w(f"[agent/kakao] 콜백 전송 실패: {type(ex).__name__}")
|
||||
|
||||
|
||||
async def _handle(body: dict, tasks: BackgroundTasks) -> dict:
|
||||
request = body.get("userRequest") or {}
|
||||
utterance = request.get("utterance") or ""
|
||||
speaker = (request.get("user") or {}).get("id") or ""
|
||||
@ -70,32 +107,37 @@ async def _handle(body: dict) -> dict:
|
||||
# 발화자를 모르면 누구의 가게인지도 모른다. 여기서 끝낸다.
|
||||
return _reply("사용자를 확인하지 못했어요.")
|
||||
|
||||
try:
|
||||
answer = await asyncio.wait_for(channel.handle(utterance, speaker), timeout=DEADLINE_SEC)
|
||||
except asyncio.TimeoutError:
|
||||
# ★ 콜백으로 나중에 미는 길은 아직 없다(오픈빌더 지원 여부 확인 필요, docs/AGENT.md).
|
||||
LOG.w("[agent/kakao] 응답 시간 초과 — 안내로 끊음")
|
||||
return _reply(_TIMEOUT_TEXT)
|
||||
except Exception as ex: # noqa: BLE001 — 메신저에서는 500 도 침묵으로 보인다
|
||||
LOG.w(f"[agent/kakao] 처리 실패: {type(ex).__name__}")
|
||||
return _reply(_ERROR_TEXT)
|
||||
# ★ 콜백이 켜져 있으면 5초 벽을 넘을 수 있다. 즉답하고 뒤에서 마저 만든다.
|
||||
callback_url = request.get("callbackUrl")
|
||||
if callback_url:
|
||||
tasks.add_task(_push, callback_url, utterance, speaker)
|
||||
return {"version": "2.0", "useCallback": True, "data": {"text": _WAIT_TEXT}}
|
||||
|
||||
return _reply(answer["text"], answer.get("quick_replies"))
|
||||
return await _answer(utterance, speaker, DEADLINE_SEC)
|
||||
|
||||
|
||||
@router.post("/webhook")
|
||||
async def webhook(request: Request, x_agent_secret: str | None = Header(default=None)):
|
||||
async def webhook(
|
||||
request: Request,
|
||||
tasks: BackgroundTasks,
|
||||
x_agent_secret: str | None = Header(default=None),
|
||||
):
|
||||
"""헤더로 시크릿을 받는 쪽. 스킬 설정에서 커스텀 헤더를 넣을 수 있으면 이쪽을 쓴다."""
|
||||
body = await request.json()
|
||||
_authorize(None, x_agent_secret, body)
|
||||
return await _handle(body)
|
||||
return await _handle(body, tasks)
|
||||
|
||||
|
||||
@router.post("/webhook/{secret}")
|
||||
async def webhook_with_path_secret(secret: str, request: Request, x_agent_secret: str | None = Header(default=None)):
|
||||
async def webhook_with_path_secret(
|
||||
secret: str,
|
||||
request: Request,
|
||||
tasks: BackgroundTasks,
|
||||
x_agent_secret: str | None = Header(default=None),
|
||||
):
|
||||
"""헤더를 못 넣는 경우의 대안.
|
||||
|
||||
★ 최후 수단이다 — 경로는 액세스 로그·앞단 프록시에 남는다. 헤더를 쓸 수 있으면 위를 쓴다."""
|
||||
body = await request.json()
|
||||
_authorize(secret, x_agent_secret, body)
|
||||
return await _handle(body)
|
||||
return await _handle(body, tasks)
|
||||
|
||||
@ -318,3 +318,55 @@ async def test_목록은_발행_여부를_같이_말한다(client, auth_headers,
|
||||
await link(db_engine, client, auth_headers, speaker, "미발행가게")
|
||||
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("목록", speaker))
|
||||
assert "아직 발행 전" in said(res)
|
||||
|
||||
|
||||
# ── 콜백 (5초 벽 넘기) ───────────────────────────────────────────────────
|
||||
|
||||
async def test_콜백이_켜져_있으면_즉답하고_뒤에서_마저_만든다(client, auth_headers, db_engine, monkeypatch):
|
||||
"""★ 오픈빌더 스킬 타임아웃은 5초다. 콜백을 쓰면 '잠시만 기다려 주세요' 를 먼저 주고
|
||||
답이 완성되면 그 주소로 따로 보낸다 — 느린 답이 침묵이 되지 않는다."""
|
||||
import router.v1.agent.kakao_bot as bot
|
||||
|
||||
sent = {}
|
||||
|
||||
async def fake_push(callback_url, utterance, speaker):
|
||||
sent["url"] = callback_url
|
||||
sent["payload"] = await bot._answer(utterance, speaker, 5.0)
|
||||
|
||||
monkeypatch.setattr(bot, "_push", fake_push)
|
||||
|
||||
payload = body("안녕", "콜백-발화자")
|
||||
payload["userRequest"]["callbackUrl"] = "https://callback.example/one-shot"
|
||||
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=payload)
|
||||
|
||||
# 즉답은 useCallback 이다 — template 을 싣지 않는다.
|
||||
assert res.json()["useCallback"] is True
|
||||
assert res.json()["data"]["text"]
|
||||
assert "template" not in res.json()
|
||||
|
||||
# 실제 답은 콜백으로 간다.
|
||||
assert sent["url"] == "https://callback.example/one-shot"
|
||||
assert "연결" in sent["payload"]["template"]["outputs"][0]["simpleText"]["text"]
|
||||
|
||||
|
||||
async def test_콜백이_없으면_예전처럼_동기로_답한다(client, db_engine):
|
||||
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("안녕", "동기-발화자"))
|
||||
assert "useCallback" not in res.json()
|
||||
assert res.json()["template"]["outputs"]
|
||||
|
||||
|
||||
async def test_콜백_전송이_실패해도_터지지_않는다(monkeypatch):
|
||||
"""★ 주소는 1분 · 1회다. 재시도하지 않는다 — 두 번째 POST 는 어차피 거절된다."""
|
||||
import router.v1.agent.kakao_bot as bot
|
||||
|
||||
monkeypatch.setattr(bot, "_answer", AsyncMock(return_value=bot._reply("답")))
|
||||
|
||||
class Boom:
|
||||
async def __aenter__(self):
|
||||
raise RuntimeError("네트워크 끊김")
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(bot.httpx, "AsyncClient", lambda **_kw: Boom())
|
||||
await bot._push("https://callback.example/x", "안녕", "누구") # 예외가 새 나오지 않는다
|
||||
|
||||
Loading…
Reference in New Issue
Block a user