Rust로 Agent 서비스 만들기…adk-rust 사용기 공개
한 개발자가 기존 서비스에 에이전트 기능을 추가하는 adk-rust 사용기와 함께 두 가지 무오류 실패 사례, 중국어 인터페이스 개조와 프로덕션 테스트 경험을 공유했다.
중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.
얼마 전 제가 운영하던 Rust 서비스 하나에 Agent 기능을 추가했습니다. 사용자가 자연어로 한마디 물으면, 서버가 스스로 도구를 고르고, 상태를 기억하고, 결과를 스트리밍으로 돌려줍니다. 예전 같으면 이런 작업은 그냥 Python으로 시작했을 겁니다. agent 생태계가 그쪽에 다 있으니까요. 이번엔 안 됐습니다. 이 기능은 이미 돌아가고 있는 서비스 안에 끼워 넣어야 했거든요. 기능 하나 때문에 Python 프로세스 하나, 의존성 한 세트, 언어를 넘나드는 호출 체인 하나를 더 유지하고, 게다가 그 메모리와 콜드 스타트까지 신경 써야 한다니, 계산이 안 맞았습니다. 그래서 Rust로 시도해봤습니다. 프레임워크는 adk-rust(Apache-2.0, 43개 crate, 모델 무관)를 골랐습니다. 최종적으로 전체 서비스는 이렇게 생겼습니다.
일단 돌려놓고 보자
제 습관은 일단 돌려놓고, 그다음에 내부 구현을 연구하는 겁니다. adk-rust에는 공식 스캐폴드가 있으니 첫 단계는 고민할 게 없었습니다.
cargo install cargo-adk cargo adk new adk-agent-service --template api --provider deepseek cd adk-agent-service && cp .env.example . env # DEEPSEEK_API_KEY 채우기 cargo run # http://127.0.0.1:8080/ui/
스캐폴드를 설치하는 데 컴파일에 1분 30초, 스켈레톤 생성에 1초, 최초 cargo build에 35초가 걸렸습니다. http://127.0.0.1:8080/ui/ 를 열면 페이지에 입력창이 하나 있고, 저는 "안녕"이라고 입력했고, 그러자 답이 돌아왔습니다. 저는 원래 문서를 먼저 파고들어 Runner, Agent, Session 이 세 층의 추상화를 먼저 이해해야 한다고 생각했는데, 스캐폴드가 생성한 main.rs에 이미 라우팅, SSE, 내장 Web UI가 다 연결돼 있었고, 제가 바꿔야 할 건 모델과 지시문뿐이었습니다.
let agent : Arc< dyn Agent> = Arc:: new ( LlmAgentBuilder:: new ( "adk-agent-service" ) . instruction ( "You are a helpful assistant…" ) . model (Arc:: new (model)) . build ()?, ); let config = ServerConfig:: new (Arc:: new (adk_rust::SingleAgentLoader:: new (agent)), session_service); let app = create_app (config);
그때부터 이 프로젝트는 해낼 수 있다는 걸 알았습니다. 남은 건 "어떻게 하면 제대로 만들까"라는 문제뿐이었습니다. 모델 쪽은 고민할 게 없었습니다. adk-model 안에서 Gemini, OpenAI, Anthropic, DeepSeek, Groq, Ollama, Bedrock이 모두 같은 방식으로 붙습니다. 제가 DeepSeek을 고른 건 순전히 키가 손에 있어서였고, 나중에 deepseek-v4-pro를 시험해보고 싶을 때도 .env에서 한 줄만 바꾸면 됐습니다.
도구는 모델에 써주는 설명서다
스캐폴드가 준 agent는 대화만 할 수 있으니, 도구를 달아줘야 합니다. adk-rust의 #[tool] 매크로는 제가 가장 좋아하는 부분입니다. 한 번에 자질구레한 세 가지 일을 다 끝내주거든요. 함수명은 도구 이름이 되고, 문서 주석은 도구 설명이 되고, 파라미터 타입의 JsonSchema는 파라미터 선언이 됩니다.
#[derive(Deserialize, JsonSchema)] pub struct CurrentTimeArgs { /// Offset from UTC in hours, e.g. 8 for Beijing, -5 for New York. Defaults to 0 (UTC). pub offset_hours: Option < f64 >, } /// Return the current date and time, in UTC and at a requested UTC offset. #[tool(read_only, concurrency_safe)] pub async fn current_time (args: CurrentTimeArgs) -> Result <Value, AdkError> { … }
등록은 builder에 붙이면 되고, 한 줄에 하나씩입니다.
. tool (Arc:: new (tools::CurrentTime)) . tool (Arc:: new (tools::Calculate)) . tool (Arc:: new (tools::Remember)) . tool (Arc:: new (tools::Recall))
여기서 한 가지 동작을 저도 한 번 해보시길 권합니다. 모델이 실제로 받는 도구 선언을 출력해서 한번 보세요. 임시 테스트만 하나 쓰면 됩니다.
fn dump (tool: & dyn Tool) { println! ( "--- {} ---" , tool. name ()); println! ( "description: {}" , tool. description ()); println! ( "schema: {}" , serde_json:: to_string (&tool. parameters_schema ()). unwrap ()); println! ( "read_only={} concurrency_safe={}" , tool. is_read_only (), tool. is_concurrency_safe ()); }
출력을 보고 나서 저는 습관 두 개를 바꿨습니다. 첫째, 파라미터의 문서 주석은 정말로 schema에 들어가고, 모델은 그것에 의존해 어떻게 채울지 결정하니, "the value"라고 대충 쓰면 안 됩니다. 둘째, remember라는 도구는 데이터를 쓰기 때문에 read_only로 표시하면 안 됩니다. 표시하면 동시에 디스패치될 수 있거든요. 반면 current_time, calculate처럼 순수하게 읽기만 하는 것들은 표시해두면 모델이 한 턴 안에서 이것들을 동시에 호출할 수 있습니다. 저는 "지금 UTC+8로 몇 시야? (12.5 + 7) * 3^2 / 4를 계산해봐. 납기일이 금요일이라고 기억해둬. 그리고 노트를 다시 한번 읽어봐."라고 물었고, 아래가 바로 그 턴의 이벤트 스트림입니다.
모델은 첫 번째 라운드에서 곧바로 세 개의 호출을 동시에 던졌습니다. 시간, 계산, 노트 기록 각각 하나씩이었고, 결과를 받은 뒤 다시 한 번 recall을 호출할지 결정하고, 마지막에야 답을 내놓았습니다. 세 개의 읽기 전용 도구는 병렬로 실행됐는데, 앞의 그 두 표시 덕분이었습니다. 저는 이 이벤트 스트림을 처음 봤을 때 정말 "오" 소리를 냈습니다. 동시 디스패치를 제가 직접 스케줄링할 필요 없이, 프레임워크가 도구 메타데이터를 보고 정했던 겁니다.
노트는 어디에 저장하지
remember / recall 이 한 쌍의 도구는 처음에 아주 단순하게 생각했습니다. 전역 HashMap<session_id, Vec<String>> 하나 만들면 끝 아니야? 라고요. 쓰기 전에 프레임워크를 한 번 더 들여다봤는데, 도구가 받는 게 ToolContext이고, 그것으로 세션 상태를 쓸 수 있다는 걸 발견했습니다.
#[tool] pub async fn remember (ctx: Arc< dyn ToolContext>, args: RememberArgs) -> Result <Value, AdkError> { let note = args.note. trim (); if note. is_empty () { return Err (AdkError:: tool ( "note must not be empty" )); } let mut notes = notes (&*ctx); notes. push (json!(note)); let mut actions = ctx. actions (); actions .state_delta . insert (NOTES_KEY. to_string (), json!(notes)); ctx. set_actions (actions); Ok (json!({ "notes" : notes, "count" : notes. len () })) }
도구는 delta 하나를 쓰는 것만 담당하고, 나머지는 runner가 합니다. session 생명주기를 제가 직접 관리할 필요도, 동시성을 제가 고려할 필요도 없습니다. 저장 백엔드(메모리, SQLite, Postgres, Redis)를 바꿀 때도 도구는 한 줄도 고칠 필요가 없습니다. 나중에 실제로 백엔드를 SQLite로 바꿨는데, 도구 코드는 정말 한 글자도 건드리지 않았습니다.
함정 하나, 도구 안에서 세션 상태를 읽으면 ctx.state()가 영원히 None
remember를 다 쓰고, recall이 읽지를 못했습니다. 영원히 count: 0을 반환하는데, curl로 세션을 조회해보면 state.notes 안에 분명히 데이터가 있었습니다. 저는 그 count: 0을 한참 들여다봤습니다. 처음엔 제가 key를 잘못 썼나 의심하고, 다음엔 delta가 커밋이 안 됐나 의심했는데, 둘 다 아니었고, 결국 소스를 뒤져서 답을 찾았습니다.
adk-rust가 도구에 넘겨주는 건 사실 AgentToolContext입니다. 그 ReadonlyContext 구현에서 user_id(), session_id()는 아주 얌전하게 부모 컨텍스트에 위임하는데, 유독 state()만 오버라이드하지 않아서, trait의 기본 구현이 잡히고, 그게 None입니다. 즉 ToolContext::state()라는 메서드는 문서에도 있고 서명상으로도 쓸 수 있지만, 이 런타임에서는 영원히 빈 값을 반환합니다. 에러도 안 나고, 경고도 없고, 그저 당신의 기능이 조용히 실패할 뿐입니다. 고치는 법은 그것을 우회해서, session()에서 state를 꺼내오고, state()는 폴백으로 남겨두는 것입니다.
fn notes (ctx: & dyn ToolContext) -> Vec <Value> { let state = ctx . state () . or_else (|| ctx. session (). map (|session| session. state ())); state . and_then (|state| state. get (NOTES_KEY)) . and_then (|value| value. as_array (). cloned ()) . unwrap_or_default () }
한 줄 폴백으로 recall이 즉시 정상이 됐습니다. 이 일이 버그 자체보다 제게 더 깊은 인상을 남겼습니다. Option은 에러를 내지 않는 실패라는 겁니다. 프레임워크가 능력을 trait에 걸어두고 기본 구현도 있고 하위 호환도 되지만, 그게 런타임에 정말 구현돼 있다는 뜻은 아닙니다. 앞으로 Option을 받으면, 저는 먼저 그것이 실제 호출에서 정말 Some인지 검증할 겁니다.
함정 둘, 가장 괴로웠던 한 번의 추적, "인터페이스가 반응이 없다"
그날 저는 브라우저에서 인터페이스를 열고, 세션을 만들고, 메시지를 보냈는데 아무 일도 일어나지 않았습니다. 답도 없고, 로딩 표시도 없고, 화면에 오류조차 없었고, 입력창은 그대로 거기 있었습니다. 방금 그 말을 아예 안 친 것처럼요. 사용자 입장에서는 딱 네 글자, "인터페이스가 반응이 없다"였습니다.
저는 먼저 서버 로그를 확인했는데, 더 이상한 걸 발견했습니다. 로그에 이번 요청이 아예 없었습니다. 그럼 요청이 제가 생각한 그 프로세스에 도달하지 않았다는 뜻입니다. 포트를 확인해보니, 역시, 8080은 제가 띄운 검증 인스턴스였고, 브라우저가 연결한 건 8090, 또 다른 인스턴스였으며, 같은 코드를 쓰고 있었습니다. 그것을 찾은 뒤, 저는 그 인스턴스에서 인터페이스가 보낼 요청을 다시 재현했습니다.
$ curl -sN -X POST .../api/run_sse -H 'x-adk-ui-protocol: adk_ui' -d '{…}' HTTP 200 content-type: text/event-stream bytes=0
HTTP 200, body는 0바이트.
그 순간 프런트엔드가 왜 반응이 없는지 이해했습니다. 그것이 받은 건 성공적인 응답, 빈 이벤트 스트림이었습니다. 이벤트가 없으면 렌더링할 게 없고, 오류가 없으면 표시할 게 없습니다. 프런트엔드는 전혀 문제가 없었습니다. 그저 아무것도 없는 서버를 충실하게 보여줬을 뿐입니다.
계속 파고들었더니 근본 원인은 두 층이었습니다. 첫째 층은 아주 멍청한 것이었습니다. 그 인스턴스의 .env에 DEEPSEEK_API_KEY가 비어 있었습니다. .env.example을 복사하고 채우는 걸 잊었던 겁니다. 둘째 층이야말로 진짜 문제였습니다. 모델의 401 오류가 이벤트 스트림에 나타나지 않았고, adk-server가 provider의 예외를 SSE 바깥으로 막아버려서, 호출하는 쪽은 "모델이 내 key가 틀렸다고 한다"와 "모델이 할 말이 없다"를 전혀 구분할 수 없었습니다.
고치는 방법은 제가 가장 직접적인 것을 골랐습니다. 시작할 때 모델을 먼저 한 번 탐지해서, 설정 문제가 첫 사용자가 밟기 전에 시작 단계에서 드러나게 하는 것입니다.
let probe = LlmRequest { model: model_id. clone (), contents: vec! [Content:: new ( "user" ). with_text ( "ping" )], config: Some (GenerateContentConfig { max_output_tokens: Some ( 1 ), .. Default :: default () }), tools: HashMap:: new (), previous_response_id: None , }; match model. generate_content (probe, false ). await ?. next (). await { Some ( Ok (_)) => tracing::info!(model = %model_id, "provider probe ok" ), Some ( Err (e)) => anyhow::bail!( "DeepSeek rejected the startup probe for '{model_id}': {e}" ), None => anyhow::bail!( "DeepSeek returned an empty startup probe for '{model_id}'" ), }
대가는 max_output_tokens=1인 호출 한 번이고, 그 대가로 얻는 것은 아래 두 줄이다.
$ DEEPSEEK_API_KEY= ./target/debug/adk-agent-service Error: DEEPSEEK_API_KEY is unset or empty — copy .env.example to .env and fill in your key $ DEEPSEEK_API_KEY=sk-bogus ./target/debug/adk-agent-service Error: DeepSeek rejected the startup probe for 'deepseek-v4-flash': model.unauthorized: DeepSeek API error (HTTP 401 Unauthorized)
덧붙여 공백값 판정도 하나 추가했다. std::env::var() 는 빈 문자열에 대해 오류가 아니라 Ok("") 를 반환하는데, 판정하지 않으면 서비스가 빈 키를 달고 아무 탈 없이 기동해 버린다.
이 일에서 규칙 하나를 배웠다. "HTTP 200인데 아무 일도 일어나지 않는" 인터페이스는, "기동하자마자 오류가 나는" 것보다 문제 추적 비용이 한 자릿수(10배) 높다. 차라리 기동이 실패하는 편이 낫지, 서비스가 잘못된 설정을 달고 조용히 실패하는 상태로 외부에 서비스를 제공하게 두어서는 안 된다. 요청 한 번이 이벤트 0개를 만들어낸다면, 그것은 이상으로 간주하고 경보를 울려야 한다.
인터페이스는 왜 중국어인가
인터페이스는 원래 영어였다. 처음에는 손댈 생각이 없었다. 돌아가기만 하면 됐으니까. 그런데 이 서비스는 팀 내부용이라, 동료들이 "Runtime target", "Interactive run", "Leaf agent · no child runtime targets" 를 보고 뜻을 추측하게 하느니 시간을 좀 들이는 게 낫겠다고 생각했다.
막상 시작해 보니 이 일은 생각보다 번거로웠다. 상위 UI는 React 단일 페이지 애플리케이션이고, 문자열이 하드코딩되어 있으며 i18n이 없다. adk-server 는 create_app() 내부에서 자신의 /ui 라우트를 무조건 마운트하고, ServerConfig 는 backend_url 하나만 노출할 뿐 끄는 스위치가 없다. 그래서 fork 한 뒤 직접 호스팅할 수밖에 없었다.
구체적으로는 여섯 단계다. 공식 v2.2.0 tag에 따라 소스를 프로젝트로 가져오고, 번역하고( App.tsx 96곳, api.ts 2곳, index.html 의 lang 과 제목), vite 산출물을 고정 파일명으로 바꾸고, include_bytes! 로 바이너리에 내장하고, middleware 한 겹을 추가해 /ui/ 아래 네 경로( /ui/ , /ui/index.html , /ui/assets/ui.js , /ui/assets/ui.css )를 단락시키고, 헤더는 프레임워크의 CSP 정책을 그대로 베꼈다.
번역할 때 내가 일부러 한 가지를 했는데, 표시 텍스트만 번역하고 내부 식별자는 하나도 건드리지 않았다. running , agent , topology 같은 상태 값은 여전히 영어이며, 이것들은 동시에 CSS 클래스명이자 로직 판단의 근거다. 앞에 매핑 상수 세 개를 두어 표시를 담당하게 했고, 로직 변경은 전혀 없다. 또 한 단계는 내가 특히 값어치 있다고 보는 것으로, 충실성 검증이다. 남의 코드를 fork 할 때 가장 두려운 것은 "내가 쓰고 있는 이게 정말 그쪽이 실제로 쓰는 그 버전인가"이다. 그래서 나는 npm ci 로 툴체인을 고정하고, 빌드 산출물의 md5 를 crates.io 의 adk-server 2.2.0 에 내장된 bundle 과 비교했다.
vendor 소스 빌드: e60ed292ee6741cddb643400a6294500 index-CAmeE16-.js crates.io 2.2.0: e60ed292ee6741cddb643400a6294500 index-CAmeE16-.js CSS 미변경: 8db1317dd99bf1793941757c464e5039 (업스트림과 일치)
바이트 단위까지 일치하니, 내 출발점이 바로 프레임워크가 실제로 내장한 그 UI임을 뜻한다. CSS도 변하지 않았다. 나는 문구만 바꿨고 스타일은 건드리지 않았다. 이게 "비슷해 보인다"보다 훨씬 든든하다.
여기서는 스크린샷 한 장을 보충하길 권한다. cargo run 후 http://127.0.0.1:8080/ui/ 를 열고, "지금 UTC+8 기준 몇 시야? 그리고 (12.5+7)*3^2/4 계산해 줘" 한마디를 보내서, 중국어 인터페이스와 도구 호출 접힘 블록을 캡처하라. 이것이 이 글 전체에서 가장 설득력 있는 그림이다.
출시 전 보충한 네 가지
기능이 돌아간 뒤, 나는 "실제 환경에 놓으면 어떻게 될까"의 순서로 한 바퀴 훑고 네 가지를 보충했다.
세션을 더 이상 메모리에 두지 말 것. 기본 InMemorySessionService 는 재시작하면 곧바로 비워진다. 개발할 때는 상관없지만, "대화 도중 서비스가 재시작되어 컨텍스트가 전부 사라지는" 그런 경험을 사용자에게 줄 수는 없다. SQLite 로 교체하는 것은 한 곳만 바꾸면 됐다. ServerConfig::new 가 받는 것이 애초에 Arc<dyn SessionService> 였으니까.
let store = SqliteSessionService:: new (& format! ( "sqlite://{path}?mode=rwc" )). await ?; store. migrate (). await ?; // 테이블 생성은 반드시 명시적으로 호출해야 함
여기 작은 함정이 하나 있다. migrate() 를 호출하지 않으면 오류가 나지 않고, 첫 번째 쓰기에서야 터진다. 실측해 보니, 노트를 쓴 뒤 프로세스를 재시작해도 GET /api/sessions/... 는 여전히 이벤트 6개에 state.notes 이고, 이어서 대화하는 데 문제가 없었다. 나중에 컨테이너에서 stop / start 도 검증해 보았다.
프로세스가 정상적으로 정지될 수 있게 할 것. podman stop 은 매번 만 10초를 기다린 뒤 SIGKILL 한다. 처음에는 이미지가 잘못 작성된 게 아닌가 의심했는데, 알아보고 나서야 원인이 아주 물리적임을 알았다. 컨테이너 안의 프로세스는 PID 1이고, PID 1은 자신이 처리(핸들링)를 인계받지 않은 신호를 무시한다. axum::serve 는 기본적으로 SIGTERM 핸들러를 설치하지 않으므로 정지 신호가 버려지고, 런타임은 타임아웃 후 강제 종료할 수밖에 없다. 핸들러를 추가한 뒤에는 로컬 SIGTERM 은 종료 코드 0, 7밀리초 만에 종료됐고, podman stop 은 10초에서 184밀리초로 바뀌었다.
컨테이너화. Rust 프로젝트에서 Docker 를 할 때 가장 짜증나는 것은 "코드 한 줄 고쳤는데 의존성 300개를 다시 컴파일"하는 것이다. Dockerfile 을 의존성 레이어와 소스 레이어로 나누면 된다. 먼저 자리만 잡은 main.rs 로 의존성만 컴파일하고, 그다음 실제 소스로 교체한다.
의존성 레이어: 먼저 매니페스트 + 자리만 잡은 main.rs 만 넣기 COPY Cargo.toml Cargo.lock ./ RUN mkdir -p src webui/dist \ && echo 'fn main() {}' > src/main.rs \ && cargo build --release --locked \ && rm -rf src # 소스 레이어: 이 crate 만 다시 컴파일 COPY src ./src COPY webui/dist ./webui/dist RUN touch src/main.rs && cargo build --release --locked
인터페이스 산출물은 저장소에 함께 커밋되어 있으므로, 빌드 단계에서 Node 조차 필요 없다. 실행 이미지는 113 MB에 불과하고, 빌드 단계의 900 MB는 이미지에 들어가지 않는다. 첫 전체 빌드는 약 2분, 이후 src/ 만 바꾸면 5.5초면 된다.
여전히 파일 하나로. include_bytes! 로 인터페이스를 바이너리에 밀어 넣으면, 배포는 파일 하나 복사하는 것이다. ldd 로 확인해 보면 libc, libm, libgcc 뿐이고, SQLite 는 안에 정적 링크되어 있으며, TLS는 순수 Rust 구현이다. 대가는 문구를 바꾸려면 다시 빌드해야 한다는 것인데, 서버 측 프로젝트에서는 본전이라 생각한다.
써 본 소감
한마디로 요약하자면, 이 프레임워크는 코드를 쓰는 건 빠르고, 문제를 해결하는 건 느리다. 두 가지가 아예 급이 다르다.
빠른 부분은 정말 빠르다. 스캐폴딩 설치부터 첫 응답까지, 중간에 막힌 적이 없었다. 도구를 작성할 때는 거의 생각할 게 없고, 불확실한 API를 만나면 나는 기본적으로 소스 코드를 직접 펴 본다. ~/.cargo/registry/src/ 에 있으니, 문서를 보는 것보다 빠르고 더 정확하다. 모델 교체, 세션 백엔드 교체 모두 "한 줄 수정" 수준의 작업이다. 이 추상화 수준은 내가 만족하는 편이다.
느린 부분은 기본적으로 "아무 일도 일어나지 않음"과 "오류를 내지 않는 실패"에 다 쏟았다. 그 401이 빈 응답으로 바뀐 건에 전후 40분을 썼는데, 두 인스턴스 대조와 SSE 원시 프레임 캡처가 포함됐다. 문서와 구현이 달라서, 공식 문서대로 작성한 요청 본문으로 422를 받아낸 데 또 10여 분을 썼다. ctx.state() 가 항상 None 인 그런 조용한 무효화는 20분 만에 위치를 잡았다.
나중에 나는 하나의 전체 인상을 정리했다. 이 프레임워크는 침묵하는 쪽을 선호한다. 거짓말은 하지 않지만, 먼저 알려 주지도 않는다. Option 은 조용히 None 을 반환하고, provider 오류는 이벤트 스트림 밖에 가로막히고, feature 이름이 한 단어만 달라도 기본 분기로 간다. 소스 코드 읽기에 익숙한 Rust 개발자라면 이런 것들은 모두 해결할 수 있다. "문서가 말한 그대로가 맞다"고 기대한다면, 좀 답답할 것이다.
장점과 단점
장점은 내가 남아서 계속 쓰는 이유다. 모델 무관, provider 교체는 한 줄 수정, 도구 개발 비용 저렴, #[tool] 이 이름 짓기, 설명, schema 를 한 번에 처리, 런타임이 얇아 기동에서 ready까지 1초 이내, 공식이 제시한 루프 오버헤드는 568 μs, 세션 추상화가 깔끔, 단일 바이너리 전달, 113 MB 이미지, 능력면 커버리지가 넓어 MCP, A2A, RAG, 평가, 텔레메트리, 인증, 실시간 음성 모두 대응하는 crate 가 있고, 문서와 예제도 적지 않아 v2.2.0 이 tag 의 examples/ 디렉터리 아래에 110개가 넘는 실행 가능한 예제가 있고 playground 에 별도로 120개가 넘으며, 내가 API를 찾을 때 여러 번 펼쳐 봤다.
단점은 미리 알아 두어야 하는 비용이다.
- 실패가 조용한 것이 가장 괴롭다. provider 이상은 이벤트 스트림에 들어가지 않고, 200에 0바이트, 프런트엔드와 호출자 모두 눈치채지 못한다.
- 문서와 코드에 차이가 있다. 같은 인터페이스가 서로 다른 문서에서 필드 스타일이 다 다르다. rest/controllers/* 소스 코드를 직접 읽는 것을 권한다.
- trait 기본 구현이 지뢰를 심는다. AgentToolContext 가 state() 를 재정의하지 않아, 도구가 읽는 세션 상태가 항상 비어 있다.
- feature 조합에 구멍이 있다. 우산 crate 에 postgres-session 은 있어도 sqlite-session 은 없어서, adk-session 을 따로 의존해야 한다.
- 내장 UI는 설정할 수 없다. i18n이 없고, /ui 도 끌 수 없다. 중국어화하려면 fork 하는 수밖에 없다.
- 컨테이너화의 세부 사항은 다루지 않는다. PID 1이 SIGTERM 을 무시하는 이런 함정은 직접 한 번 밟아 봐야 한다.
- 아직 자리만 잡은 모듈도 있다. 나는 소스 코드에서 rest/routes.rs 가 주석 한 줄만 남아 있는 것을 봤고, cargo-adk/src/cli.rs 에는 "Will be implemented in a later task"라고 적혀 있다. 프로덕션에 올리기 전에 네가 쓰려는 모듈이 완성된 상태인지 확인하는 게 좋다.
누구에게 맞는가. 단일 바이너리, 낮은 메모리, 빠른 콜드 스타트를 요구하는 시나리오, 예를 들어 엣지 게이트웨이, CLI 도구, 임베디드 서비스, 이미 Rust 서비스가 있어 여기에 agent 능력을 더하고 싶은 경우도 되고, 그리고 소스 코드를 읽을 의향이 있는 사람.
누구에게 맞지 않느냐. 주로 "프롬프트와 흐름을 빠르게 실험"하는 팀인데, 그런 속도에서는 Python 생태계의 반복 속도 우위가 실질적이다. 특정 Python 전용 통합에 강하게 의존하는 상황도 마찬가지다. 또한 팀에 Rust 경험이 전혀 없다면, 갚아야 할 빚은 Async, 트레이트 객체, 피처 조합 이 세 가지이니 각오를 해야 한다.
다시 한다면
- 먼저 기동 프로브(十행 코드)를 쓰고, 그다음 비즈니스를 쓴다. 이 한 가지가 내가 그때 가장 괴로웠던 트러블슈팅을 없애준다.
- 먼저 브라우저 F12로 내장 인터페이스가 도대체 어느 endpoint를 치고 body가 어떻게 생겼는지 한 번 보고 나서 클라이언트를 작성하면, 문서를 읽는 것보다 한 자릿수 빠르다.
- 먼저 parameters_schema()를 출력해서 확인하라, 그것이 바로 모델이 보는 계약이다.
- 세션 백엔드는 처음부터 SQLite를 써라. 인메모리 백엔드는 CI에만 적합하고, "재시작하면 데이터가 사라진다"는 것은 이미 다 썼다고 생각한 기능을 포함해 많은 문제를 가려버린다.
- 컨테이너화한 당일에 SIGTERM 처리를 끝내라, 그렇지 않으면 배포할 때마다 타임아웃을 꽉 채워 기다려야 한다.
- "빈 응답"에 모니터링을 붙여라. 한 번의 요청이 0개의 이벤트를 산출하면, 이상으로 간주해야 한다.
마지막으로
전체 프로젝트의 최종 모습은 이렇다, src/ 세 개 파일, 543줄의 코드로, 중국어 인터페이스가 기본 제공되고 세션이 영속화되며 컨테이너에 넣을 수 있고 단일 파일로 배포되는 agent 서비스를 얻었다. Rust로 agent를 쓰는 것은 지금 확실히 Python처럼 "손 뻗으면 바로 있는" 그런 것은 아니지만, 기존 서비스에 넣을 수 있고 하나의 바이너리로 돌아가며 추가 런타임이 없다는 이 점은 다른 방안이 쉽게 주기 어려운 것이다. adk-rust는 나름의 거친 부분이 있고, 위의 그 함정들은 모두 사실이며, 써본 전체적인 느낌은 그것이 올바른 방향으로 자라고 있다는 것이다.
코드와 삽화는 모두 내 로컬 adk-agent-service/ 디렉터리에 있고, 재현하고 싶다면 본문의 명령을 따라 한 번 돌려보면 된다. 질문이 있으면 댓글로 이야기하자, 특히 너도 "200인데 아무 일도 일어나지 않았다" 같은 함정을 밟아봤다면, 그때 어떻게 위치를 잡았는지 무척 궁금하다.
(참고 adk-rust 저장소 · crates.io · docs.rs )
독립 개발자 아핑
91
글
22k
읽음
46
팬