도구 개념의 스크래핑·크롤링에서 “문서 페이지를 마크다운으로”를 예로 들었습니다. 검색이 어디를 볼지 찾는 일이라면, 스크래핑은 그 페이지를 통째로 읽어 오는 일입니다. 이 글에서는 그 예시를 실제로 돌아가는 에이전트로 만들어 봅니다.
무엇을 만드나
URL이 담긴 작업을 받으면 모델이 Firecrawl로 그 페이지를 마크다운으로 가져오고, 내용을 읽어 요약·답변하는 에이전트입니다.
flowchart LR
q["작업 — 이 문서 읽고 요약"] --> model["모델"]
model -- URL --> tool["scrape · Firecrawl"]
tool --> md[("마크다운")]
md --> model
model --> ans["요약 · 답변"]
class model roleModel
class tool roleTool
class md roleSource검색이 출처를 가리키는 데 그친다면, 스크래핑은 그 페이지의 본문 자체를 모델 앞에 펼쳐 놓습니다.
코드 뜯어보기
전체 구조
app.py의 전체 흐름은 함수 셋으로 나뉩니다 — main()이 모델과 에이전트를 짜고, 모델이 부르는 도구가 scrape(), 그리고 마지막 답을 다듬는 게 message_text()입니다.
flowchart TB
q["작업 (sys.argv)"] --> build["main() — 모델·에이전트 구성"]
build --> react{"ReAct 루프"}
react -- 추론 --> model["모델 (ChatLiteLLM)"]
model -- 도구 호출 --> tool["scrape(url)"]
tool --> fc[("Firecrawl.scrape_url")]
fc -- markdown --> tool
tool -- 관찰 --> react
react -- 종료 --> msg["message_text(content)"]
msg --> out["print → stdout"]
class model roleModel
class tool roleTool
class fc roleSource세부 구조
scrape(url) — 도구
@tool로 감싼 함수 하나 — 모델은 docstring을 보고 언제 페이지를 읽을지 판단_firecrawl.scrape_url(url, formats=["markdown"])로 Firecrawl 호출- 반환이 객체든 딕셔너리든
markdown을 꺼내 사용 (SDK 버전 차이 흡수) - 긴 페이지가 프롬프트를 넘치지 않게 앞부분 6,000자로 자름
@tool
def scrape(url: str) -> str:
"""Fetch a web page and return its content as clean Markdown.
Use this to read what's actually behind a URL — docs, articles, anything the
model can't see on its own. Pass a single absolute URL.
"""
res = _firecrawl.scrape_url(url, formats=["markdown"])
md = getattr(res, "markdown", None)
if md is None and isinstance(res, dict): # older SDKs return a dict
md = res.get("markdown") or res.get("data", {}).get("markdown")
# Cap the length so a long page doesn't blow up the prompt.
return (md or "")[:6000] or "No content."
message_text(content) — 출력 다듬기
- 모델 응답의
content는 모양이 제각각 — 클라우드는 문자열, 일부 로컬 모델은[{type: "text", …}, …]블록 리스트 - 리스트면
type == "text"블록의 텍스트만 이어붙임 - 문자열이면 그대로 둠
- 그래서 어느 제공자든 깔끔한 한 줄로 출력
def message_text(content) -> str:
"""Flatten an assistant message's content to plain text (cloud models return a
string; some local models return a list of blocks)."""
if isinstance(content, list):
return "".join(
part.get("text", "")
for part in content
if isinstance(part, dict) and part.get("type") == "text"
)
return content
main() — 조립
MODEL로ChatLiteLLM을 만들고create_agent(model, tools=[scrape])로 ReAct 루프 구성ChatLiteLLM은 LiteLLM을 LangChain 모델로 감싼 어댑터 — 라우팅(어느 API를 부를지)은 LiteLLM, 인터페이스 연결은ChatLiteLLMagent.invoke({"messages": […]})가 추론→도구 호출→관찰을 돌림- 끝나면 마지막 메시지의
content를message_text()로 다듬어 출력
def main() -> None:
question = " ".join(sys.argv[1:]) or "https://docs.firecrawl.dev 를 읽고 핵심 기능 3가지만 요약해줘"
# MODEL chooses the provider (claude-opus-4-8 / gpt-4o / gemini/gemini-2.5-flash).
model = ChatLiteLLM(model=os.environ.get("MODEL", "claude-opus-4-8"), temperature=0)
agent = create_agent(model, tools=[scrape])
result = agent.invoke({"messages": [{"role": "user", "content": question}]})
print(message_text(result["messages"][-1].content))
임포트는 langchain이지만 create_agent가 돌려주는 것은 LangGraph 그래프입니다.
이 접착 계층 없이 LiteLLM + LangGraph만으로 직접 구성하면 어떻게 되는지는 별도 비교 글에서 다룹니다.
구현
LangGraph의 ReAct 루프에 scrape 도구 하나만 붙였습니다.
모델은 LiteLLM을 통해 라우팅되므로 같은 코드가 Claude·OpenAI·Gemini에서 모두 동작합니다.
samples/firecrawl_12026년 6월 28일핵심만 짚으면
- 도구는 함수다 —
@tool로 감싼scrape(url)하나가 페이지를 읽는 전부입니다. - 검색과 스크래핑은 다르다 — 검색은 어디를 볼지 찾고, 스크래핑은 그 페이지를 읽어 옵니다.
- 결과는 근거가 된다 — Firecrawl이 돌려준 마크다운이 추론에 들어가, 링크 너머의 실제 내용으로 답합니다.
- 제공자는 갈아끼운다 —
.env의MODEL만 바꾸면 같은 코드로 다른 모델을 씁니다.
같은 루프에서 도구만 검색(Tavily)으로 바꾸면 오늘의 환율 묻기가 되고, 브라우저 자동화로 바꾸면 또 다른 “지금” 데이터를 끌어올 수 있습니다.
도구의 갈래는 도구 개념에 정리해 두었습니다.


