두 AI가 서로를 탓했다 — 프로젝트마다 에이전트를 붙였을 때 생기는 일

프로젝트마다 붙인 에이전트가 각자 자기 로그만 보고 결백을 주장해서, 양쪽 로그를 겹쳐 놓고 결론을 낸 기록

공시 요약 배치를 돌리자 애플리케이션 API가 뻗었다. 모델 서버와 요약 애플리케이션은 별개 프로젝트라 Claude Code 세션도 따로 떠 있는데, 양쪽에 물었더니 각자 자기 로그만 보고 상대를 지목했다. 그 사이에서 말을 옮기는 게 내 일이 됐다.

하려던 것

공시 요약 배치를 돌렸더니 애플리케이션 API가 뻗었다. 원인을 찾아야 했다.

여기까지는 평범한 장애다. 특이했던 건 디버깅 구조였다.

어떻게 시켰나

나는 두 프로젝트에 Claude Code 세션을 각각 띄워 놓고 쓴다. 하나는 모델 서버를 보고, 하나는 요약 애플리케이션을 본다. 장애가 나서 각자에게 물었다.

그리고 각자 자기 로그만 보고 각자 결백을 주장했다.

공시 쪽에 물으니 모델 서버가 못 버틴 거라고 했다. 그래서 서버 쪽에 가져갔더니 그쪽은 요청을 다 처리했다고 했다. 다시 공시 쪽으로 갔다.

“서버 쪽 로그 확인해 볼 수 있어? 걔는 네 잘못이래. 전혀 문제 없었다던데.”

나는 두 AI 사이에서 메신저를 하고 있었다. 각자 자기 컨테이너 로그, 자기 코드베이스, 자기 대화 히스토리만 갖고 있고, 상대 시스템은 “저쪽에서 그렇게 말한다더라”라는 전언으로만 안다.

그리고 전언은 각색된다. 내가 A의 말을 B에게 옮기면 B는 그걸 자기 맥락에서 해석해 반박을 만들고, 그 반박을 다시 A에게 옮기면 A가 또 자기 맥락에서 해석한다. 두 번 왕복하니 원래 무슨 말이었는지 나도 헷갈렸다. 사람 조직에서 부서 간에 벌어지는 일이랑 똑같은데, 속도만 훨씬 빠르다.

어디서 막혔나

원인 찾기부터 막혔다. 그라파나에는 500이 잔뜩 찍혀 있는데 애플리케이션 로그에는 해당 시간대가 비어 있었다. 시간대를 몇 번 바꿔 물어봐도 “그 시각 로그가 없다”는 답만 돌아왔다.

로그는 유실되거나 레벨에 걸려 안 남을 수 있지만, 카운터가 500을 셌으면 500이 난 거다. 그러면 지표를 믿어야 한다. 그런데 에이전트는 자기가 확인할 수 있는 쪽(로그 없음)을 근거로 삼으려 했다.

그리고 자꾸 선을 넘었다. 공시 쪽 에이전트가 장애를 풀겠다고 다른 프로젝트의 서버 설정(max_model_len)을 건드리려 했다. 그쪽 사정은 모르는 채로. 그 값은 서버를 세울 때 컨텍스트 길이와 메모리를 같이 보고 고른 자리다. 여기서는 그 맥락이 안 보인다.

에이전트는 자기 프로젝트 안에서는 놀랄 만큼 유능한데, 경계 밖 사정을 모른다는 걸 스스로 모른다.

어떻게 풀었나

양쪽 로그를 겹쳐 놓고 나서 결론이 나왔다.

“나는 전체 처리량을 늘리고 싶은 거지, 타임아웃을 늘리자는 게 아니야. 서버에선 에러가 없거든. 요청한 거 다 처리해 줬어. 네가 못 받아온 거지.”

모델 서버 로그에는 에러가 없다. 요청은 다 처리됐다. 받는 쪽이 못 받은 거다.

그럼 받는 쪽에서 뭐가 말랐나. DB 커넥션이었다. 당시 분석 한 건은 대상을 DB에서 읽고, 공시 원문을 받아 오고, AI를 부르고, 결과를 쓰는 순서로 흘렀는데 이게 한 트랜잭션 안에 있었다. AI 호출은 읽기 타임아웃이 120초짜리 블로킹 호출이다. 수십 초짜리 외부 호출 내내 DB 커넥션 하나를 붙잡고 있었다는 뜻이다. 풀은 열 개다. 그러니 동시에 열 건만 물려도 그다음 요청은 커넥션을 못 받는다. 애플리케이션 API가 뻗은 건 그래서다. 모델 서버는 정말 잘못이 없었다.

해결책을 찾는 데서는 한참 돌았다. 처음 나온 안은 타임아웃을 늘리자는 것이었는데 그건 증상만 미루는 거다. 그다음은 비동기 콜백. 그러면 콜백 유실을 대비해야 하고, 그러려면 상태를 둘 자리가 필요하다. gRPC 얘기까지 나왔다. 구조물이 계속 늘었다.

실제로 그날 저녁에 한 일은 커밋에 그대로 남아 있다.

시각한 일
19:37수집과 분석을 떼어 냈다. 공시를 저장할 때 분석 대기 행을 같이 넣고, 분석은 1분마다 도는 스케줄러가 맡는다
20:00대기 행을 집어 올 때 한 번에 50건까지만 가져오게 했다
20:51처리 중 상태를 따로 두고 스레드 열 개로 병렬 처리한다. 여러 인스턴스가 같은 행을 집지 않게 잠금을 걸고, 5분 넘게 처리 중인 행은 대기로 되돌린다
22:08흐름을 네 단계로 쪼개 외부 호출 구간에서는 DB 커넥션을 놓게 했다
23:40스케줄러를 멈추고 재개하는 관리자 API와 진행 중 스레드 수를 붙였다

22시 8분 커밋에 한 줄이 적혀 있다. 커넥션 열 개로 스레드 스무 개를 돌릴 수 있게 됐다고. 풀을 늘려서 얻은 게 아니라 붙잡는 시간을 줄여서 얻은 여유다.

한 시간 동안 그린 설계 중에 새로 들인 건 없다. 다만 비동기 콜백을 검토할 때 필요하다고 했던 “상태를 둘 자리”는 결국 필요했다. 새 큐를 들이는 대신 이미 있던 분석 캐시 테이블에 대기와 처리 중 상태를 얹어 큐로 썼다. 큐가 필요 없었던 게 아니라, 있는 걸로 됐던 것이다.

배운 점