AI 던전 게임
모듈 3: 스토리 API 구현
StoryApi는 Game
과 컨텍스트용 Action
목록을 입력받아 스토리를 진행시키는 단일 API generate_story
로 구성됩니다. 이 API는 Python/FastAPI로 스트리밍 API로 구현되며, 생성된 코드를 목적에 맞게 수정하는 방법도 함께 설명합니다.
API 구현
API를 생성하려면 먼저 몇 가지 추가 종속성을 설치해야 합니다.
- Amazon Bedrock 호출에
boto3
사용 - Lambda Web Adapter (LWA)와 함께 사용할 때 API 시작을 위해
uvicorn
사용 bundle
작업 업데이트 시 크로스 플랫폼 파일 복사를 지원하기 위한 npm 종속성copyfiles
다음 명령어로 종속성을 설치합니다:
pnpm nx run dungeon_adventure.story_api:add --args boto3 uvicorn
yarn nx run dungeon_adventure.story_api:add --args boto3 uvicorn
npx nx run dungeon_adventure.story_api:add --args boto3 uvicorn
bunx nx run dungeon_adventure.story_api:add --args boto3 uvicorn
pnpm add -Dw copyfiles
yarn add -D copyfiles
npm install --legacy-peer-deps -D copyfiles
bun install -D copyfiles
이제 packages/story_api/story_api/main.py
내용을 다음으로 교체합니다:
import json
from boto3 import clientfrom fastapi.responses import PlainTextResponse, StreamingResponsefrom pydantic import BaseModel
from .init import app, lambda_handler
handler = lambda_handler
bedrock = client('bedrock-runtime')
class Action(BaseModel): role: str content: str
class StoryRequest(BaseModel): genre: str playerName: str actions: list[Action]
async def bedrock_stream(request: StoryRequest): messages = [ {"role": "user", "content": "Continue or create a new story..."} ]
for action in request.actions: messages.append({"role": action.role, "content": action.content})
response = bedrock.invoke_model_with_response_stream( modelId='anthropic.claude-3-sonnet-20240229-v1:0', body=json.dumps({ "system":f""" You are running an AI text adventure game in the {request.genre} genre. Player: {request.playerName}. Return less than 200 characters of text. """, "messages": messages, "max_tokens": 1000, "temperature": 0.7, "anthropic_version": "bedrock-2023-05-31" }) )
stream = response.get('body') if stream: for event in stream: chunk = event.get('chunk') if chunk: message = json.loads(chunk.get("bytes").decode()) if message['type'] == "content_block_delta": yield message['delta']['text'] or "" elif message['type'] == "message_stop": yield "\n"
@app.post("/story/generate", openapi_extra={'x-streaming': True, 'x-query': True}, response_class=PlainTextResponse)def generate_story(request: StoryRequest) -> str: return StreamingResponse(bedrock_stream(request), media_type="text/plain")
위 코드 분석:
- 클라이언트 SDK 생성 시 스트리밍 API임을 나타내기 위해
x-streaming
설정 사용. 이를 통해 타입 안전성을 유지하면서 스트리밍 방식으로 API 소비 가능! - POST 요청이지만
mutation
대신query
로 처리하기 위해x-query
설정 사용. TanStack Query가 스트리밍 상태를 관리할 수 있도록 함 media_type="text/plain"
과response_class=PlainTextResponse
로 단순 텍스트 스트림 반환
인프라
이전에 설정한 인프라는 모든 API가 Lambda와 통합하는 API Gateway를 사용한다고 가정합니다. 하지만 story_api
의 경우 스트리밍 응답을 지원하지 않는 API Gateway 대신 응답 스트리밍이 구성된 Lambda 함수 URL을 사용합니다.
이를 지원하기 위해 CDK 구성을 다음과 같이 업데이트합니다:
import { Construct } from 'constructs';import { CfnOutput, Duration, Stack } from 'aws-cdk-lib';import { CorsHttpMethod, HttpApi as _HttpApi, HttpMethod, IHttpRouteAuthorizer,} from 'aws-cdk-lib/aws-apigatewayv2';import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';import { Code, Function, FunctionUrl, FunctionUrlAuthType, InvokeMode, LayerVersion, Runtime, Tracing,} from 'aws-cdk-lib/aws-lambda';import { Grant, IGrantable } from 'aws-cdk-lib/aws-iam';import { RuntimeConfig } from './runtime-config.js';
export interface HttpApiProps { readonly apiName: string; readonly handler: string; readonly handlerFilePath: string; readonly runtime: Runtime; readonly defaultAuthorizer: IHttpRouteAuthorizer; readonly apiType?: 'api-gateway' | 'function-url-streaming'; readonly allowedOrigins?: string[];}
export class HttpApi extends Construct { public readonly api?: _HttpApi; public readonly routerFunctionUrl?: FunctionUrl; public readonly routerFunction: Function;
constructor(scope: Construct, id: string, props: HttpApiProps) { super(scope, id);
this.routerFunction = new Function(this, `${id}Handler`, { timeout: Duration.seconds(30), runtime: props.runtime, handler: props.handler, code: Code.fromAsset(props.handlerFilePath), tracing: Tracing.ACTIVE, environment: { AWS_CONNECTION_REUSE_ENABLED: '1', }, });
let apiUrl; if (props.apiType === 'function-url-streaming') { const stack = Stack.of(this); this.routerFunction.addLayers( LayerVersion.fromLayerVersionArn( this, 'LWALayer', `arn:aws:lambda:${stack.region}:753240598075:layer:LambdaAdapterLayerX86:24`, ), ); this.routerFunction.addEnvironment('PORT', '8000'); this.routerFunction.addEnvironment( 'AWS_LWA_INVOKE_MODE', 'response_stream', ); this.routerFunction.addEnvironment( 'AWS_LAMBDA_EXEC_WRAPPER', '/opt/bootstrap', ); this.routerFunctionUrl = this.routerFunction.addFunctionUrl({ authType: FunctionUrlAuthType.AWS_IAM, invokeMode: InvokeMode.RESPONSE_STREAM, cors: { allowedOrigins: props.allowedOrigins ?? ['*'], allowedHeaders: [ 'authorization', 'content-type', 'x-amz-content-sha256', 'x-amz-date', 'x-amz-security-token', ], }, }); apiUrl = this.routerFunctionUrl.url; } else { this.api = new _HttpApi(this, id, { corsPreflight: { allowOrigins: props.allowedOrigins ?? ['*'], allowMethods: [CorsHttpMethod.ANY], allowHeaders: [ 'authorization', 'content-type', 'x-amz-content-sha256', 'x-amz-date', 'x-amz-security-token', ], }, defaultAuthorizer: props.defaultAuthorizer, });
this.api.addRoutes({ path: '/{proxy+}', methods: [ HttpMethod.GET, HttpMethod.DELETE, HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH, HttpMethod.HEAD, ], integration: new HttpLambdaIntegration( 'RouterIntegration', this.routerFunction, ), }); apiUrl = this.api.url; }
new CfnOutput(this, `${props.apiName}Url`, { value: apiUrl! });
RuntimeConfig.ensure(this).config.httpApis = { ...RuntimeConfig.ensure(this).config.httpApis!, [props.apiName]: apiUrl, }; }
public grantInvokeAccess(grantee: IGrantable) { if (this.api) { Grant.addToPrincipal({ grantee, actions: ['execute-api:Invoke'], resourceArns: [this.api.arnForExecuteApi('*', '/*', '*')], }); } else if (this.routerFunction) { Grant.addToPrincipal({ grantee, actions: ['lambda:InvokeFunctionUrl'], resourceArns: [this.routerFunction.functionArn], conditions: { StringEquals: { 'lambda:FunctionUrlAuthType': 'AWS_IAM', }, }, }); } }}
import { Construct } from 'constructs';import * as url from 'url';import { HttpApi } from '../../core/http-api.js';import { HttpIamAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2-authorizers';import { Runtime } from 'aws-cdk-lib/aws-lambda';import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam';
export class StoryApi extends HttpApi { constructor(scope: Construct, id: string) { super(scope, id, { defaultAuthorizer: new HttpIamAuthorizer(), apiName: 'StoryApi', runtime: Runtime.PYTHON_3_12, handler: 'story_api.main.handler', apiType: 'function-url-streaming', handler: 'run.sh', handlerFilePath: url.fileURLToPath( new URL( '../../../../../../dist/packages/story_api/bundle', import.meta.url, ), ), });
this.routerFunction.addToRolePolicy( new PolicyStatement({ effect: Effect.ALLOW, actions: ['bedrock:InvokeModelWithResponseStream'], resources: [ 'arn:aws:bedrock:*::foundation-model/anthropic.claude-3-sonnet-20240229-v1:0', ], }), ); }}
이제 Lambda Web Adapter 배포를 지원하도록 story_api
를 업데이트합니다.
#!/bin/bash
PATH=$PATH:$LAMBDA_TASK_ROOT/bin \ PYTHONPATH=$PYTHONPATH:/opt/python:$LAMBDA_RUNTIME_DIR \ exec python -m uvicorn --port=$PORT story_api.main:app
{ "name": "dungeon_adventure.story_api", "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "application", "sourceRoot": "packages/story_api/story_api", "targets": { ... "bundle": { "cache": true, "executor": "nx:run-commands", "outputs": ["{workspaceRoot}/dist/packages/story_api/bundle"], "options": { "commands": [ "uv export --frozen --no-dev --no-editable --project story_api -o dist/packages/story_api/bundle/requirements.txt", "uv pip install -n --no-installer-metadata --no-compile-bytecode --python-platform x86_64-manylinux2014 --python `uv python pin` --target dist/packages/story_api/bundle -r dist/packages/story_api/bundle/requirements.txt", "copyfiles -f packages/story_api/run.sh dist/packages/story_api/bundle" ], "parallel": false }, "dependsOn": ["compile"] }, ... }}
배포 및 테스트
먼저 코드베이스를 빌드합니다:
pnpm nx run-many --target build --all
yarn nx run-many --target build --all
npx nx run-many --target build --all
bunx nx run-many --target build --all
다음 명령어로 애플리케이션을 배포할 수 있습니다:
pnpm nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox
yarn nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox
npx nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox
bunx nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox
배포는 약 2분 정도 소요됩니다.
Deployment command
다음 명령어로 CDK 애플리케이션의 모든 스택을 배포할 수 있습니다:
pnpm nx run @dungeon-adventure/infra:deploy --all
yarn nx run @dungeon-adventure/infra:deploy --all
npx nx run @dungeon-adventure/infra:deploy --all
bunx nx run @dungeon-adventure/infra:deploy --all
단, infra-prod
와 같이 배포 단계를 별도 스택으로 분리한 경우 --all
플래그가 원치 않는 배포를 유발할 수 있으므로 권장하지 않습니다!
배포가 완료되면 다음과 유사한 출력을 확인할 수 있습니다(일부 값 생략):
dungeon-adventure-infra-sandboxdungeon-adventure-infra-sandbox: deploying... [2/2]
✅ dungeon-adventure-infra-sandbox
✨ Deployment time: 354s
Outputs:dungeon-adventure-infra-sandbox.ElectroDbTableTableNameXXX = dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYYdungeon-adventure-infra-sandbox.GameApiGameApiUrlXXX = https://xxx.region.amazonaws.com/dungeon-adventure-infra-sandbox.GameUIDistributionDomainNameXXX = xxx.cloudfront.netdungeon-adventure-infra-sandbox.StoryApiStoryApiUrlXXX = https://xxx.lambda-url.ap-southeast-2.on.aws/dungeon-adventure-infra-sandbox.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxxdungeon-adventure-infra-sandbox.UserIdentityUserIdentityUserPoolIdXXX = region_xxx
API 테스트 방법:
- FastAPI 서버를 로컬에서 실행하고
curl
로 API 호출 - 배포된 API를 sigv4 활성화 curl로 직접 호출
Sigv4 enabled curl
.bashrc
파일에 다음 스크립트를 추가하거나 실행할 터미널에 직접 붙여넣기:~/.bashrc acurl () {REGION=$1SERVICE=$2shift; shift;curl --aws-sigv4 "aws:amz:$REGION:$SERVICE" --user "$(aws configure get aws_access_key_id):$(aws configure get aws_secret_access_key)" -H "X-Amz-Security-Token: $(aws configure get aws_session_token)" "$@"}sigv4 인증 curl 요청 예시:
API Gateway
Terminal window acurl ap-southeast-2 execute-api -X GET https://xxx스트리밍 Lambda 함수 URL
Terminal window acurl ap-southeast-2 lambda -N -X POST https://xxx
다음 명령어로 로컬 FastAPI 서버 실행:
pnpm nx run dungeon_adventure.story_api:serve
yarn nx run dungeon_adventure.story_api:serve
npx nx run dungeon_adventure.story_api:serve
bunx nx run dungeon_adventure.story_api:serve
서버 실행 후 다음 명령어로 호출:
curl -N -X POST http://127.0.0.1:8000/story/generate \ -d '{"genre":"superhero", "actions":[], "playerName":"UnnamedHero"}' \ -H "Content-Type: application/json"
acurl ap-southeast-2 lambda -N -X POST \ https://xxx.lambda-url.ap-southeast-2.on.aws/story/generate \ -d '{"genre":"superhero", "actions":[], "playerName":"UnnamedHero"}' \ -H "Content-Type: application/json"
명령어가 성공하면 다음과 같은 스트리밍 응답을 확인할 수 있습니다:
UnnamedHero stood tall, his cape billowing in the wind....
축하합니다. 여러분은 FastAPI를 사용하여 첫 번째 API를 구축하고 배포했습니다! 🎉🎉🎉