Langfuse v4, day 2 (3 of 10): two well-known agents instrumented, Open Deep Research and GPT Researcher
Contents
Third article in a series of ten about operating Langfuse v4 in production. The first walked through what goes into a trace; the second measured what an agent turn costs over a minimal graph. This one applies the same to two agents people deploy. Code read on 14 September 2026: Open Deep Research at commit
1b7d2e8and GPT Researcher at6f99857, with Langfuse 4.15.2 and LangGraph 1.2.11.
TL;DR
One Open Deep Research request with the default configuration is 1,416 observations and 8.1 MB. Measured over a faithful replica of its topology with five concurrent units, six supervisor iterations and ten react calls per researcher, which are its defaults. It all lands in a single trace. And it is a floor, because on the bench the search results are three hundred characters and real ones are much larger.
None of those 1,416 is of type AGENT. Its nine nodes are called clarify_with_user, write_research_brief, research_supervisor, final_report_generation, supervisor, supervisor_tools, researcher, researcher_tools and compress_research. None contains the string the handler looks for.
The parallel fan-out does not break the trace, and the fear that it does is unfounded. Open Deep Research forwards the config to its subgraphs and GPT Researcher passes a fresh one carrying only tags. I tested both patterns: one trace in both cases, because LangChain rebuilds the config from the context variable.
GPT Researcher is two products in one repository. The multi-agent mode is LangGraph and traces like any graph. The standard mode has no graph, and there every model call opens its own trace: eight calls, eight traces, measured.
Its human node does not use interrupt(), it waits on a websocket. The node’s observation stays open while the person thinks, so its duration measures human latency. The 95th percentile of that graph means nothing unless that node is excluded.
Instrumenting both requires no change to their repositories. For Open Deep Research it is enough to import the compiled graph and pass the handler through config. For GPT Researcher’s standard mode you have to wrap with the SDK, because there is nowhere to hook callbacks.
You are here: OBSERVE, day 2
The previous article produced a formula over a toy graph: 4 + 5n observations per turn, with the byte volume growing with the square of the steps. A formula over a toy is good for understanding the mechanism and no good for sizing anything.
This article applies it to two real agents, chosen because they are well known and because they break in different ways. The series grows to ten articles and the migration one moves along one place.
The analogy: two factories, one audit
A traceability audit in a factory that runs the whole process on its own lines is tedious but complete: every station leaves a record, and the auditor reconstructs the part from beginning to end. The volume of paper is enormous and that is the only problem.
The second factory subcontracts half the process. The auditor sees material going into a building and finished product coming out, with times and quantities, and has nothing about what happens inside. There is little paper. The problem is not the paper either.
Open Deep Research is the first factory and the problem is volume. GPT Researcher, in its standard mode, is the second, and the problem is that the trace does not carry what you need to know. Both are fixable, with different tools, and confusing them leads to buying disk when what is missing is instrumentation.
The two agents, and why these
Open Deep Research (langchain-ai/open_deep_research, 12.7 k stars) is LangChain’s reference research agent. All its work goes through LangChain, so callback instrumentation sees it whole.
GPT Researcher (assafelovic/gpt-researcher, 28.4 k stars) is the best-known open source research agent and it is not from LangChain. Its multi-agent mode uses LangGraph; its standard mode, which is what the API and the web interface run, does not.
One is in-house and the other is not, which for a comparison is what matters.
Open Deep Research: the shape of the graph
Three graphs, two of them nested as subgraphs (src/open_deep_research/deep_researcher.py):
- The root graph,
deep_researcher(line 701), with four nodes:clarify_with_user,write_research_brief,research_supervisorandfinal_report_generation. - The supervisor subgraph (line 353), with
supervisorandsupervisor_tools, iterating between them. - The researcher subgraph (line 589), with
researcher,researcher_toolsandcompress_research, which is a classic react loop.
The multiplication lives in supervisor_tools. When the supervisor decides to delegate, that node launches one researcher subgraph per unit and awaits them together (line 305):
research_tasks = [
researcher_subgraph.ainvoke({
"researcher_messages": [HumanMessage(content=tool_call["args"]["research_topic"])],
"research_topic": tool_call["args"]["research_topic"]
}, config)
for tool_call in allowed_conduct_research_calls
]
tool_results = await asyncio.gather(*research_tasks)
The detail that decides whether the trace comes out whole is that config at the end: the node receives LangGraph’s configuration and forwards it to the subgraphs, so the callbacks travel and every researcher hangs off the right place.
The three ceilings in the default configuration (src/open_deep_research/configuration.py) are max_concurrent_research_units = 5, max_researcher_iterations = 6 and max_react_tool_calls = 10. They multiply each other.
What one request costs, measured
I replicated that topology with fake models, the same technique as the previous article: three graphs, the same nesting, the same fan-out with asyncio.gather, three-hundred-character search results and two-hundred-character model replies. No network and no GPU, so it reproduces anywhere.
| Units | Iterations | React calls | Observations | generation | chain | Attribute KB | Largest observation |
|---|---|---|---|---|---|---|---|
| 1 | 1 | 1 | 26 | 8 | 18 | 56.8 KB | 3.7 KB |
| 2 | 2 | 3 | 92 | 26 | 66 | 257.3 KB | 4.6 KB |
| 5 | 6 | 10 | 1,416 | 370 | 1,046 | 8,108 KB | 11.0 KB |
The last row is the repository’s default configuration. One research request, one person pressing a button once, leaves 1,416 observations and eight megabytes in the observability backend. In a single trace, under a single identifier.
Those eight megabytes are a floor, not a ceiling. On the bench each search result takes three hundred characters; a real Tavily result carrying page content takes between ten and a hundred times more, and that text enters the history and gets re-serialised in every later observation of the same researcher, which is the previous article’s quadratic growth acting on much larger text.
Put into capacity arithmetic: a hundred research requests a day, which for a small team is nothing, is 141,600 observations and on the order of a gigabyte a day uncompressed, with toy searches. With real searches, tens of gigabytes. It is another order of magnitude compared with a chat assistant, and it is the reason this article comes before the capacity one.
Zero AGENT observations
Among those 1,416 observations there is not one of type AGENT. The previous article explained why: the handler decides that type by looking for the string agent in the class path or the run name. Open Deep Research’s nine nodes are clarify_with_user, write_research_brief, research_supervisor, final_report_generation, supervisor, supervisor_tools, researcher, researcher_tools and compress_research.
They are good names. They describe what each node does and they leave out a word that adds nothing for whoever reads the code. The result is that LangChain’s reference research agent, seen from Langfuse, is a tree of chains.
It is fixable from outside, without touching the repository, by naming the subgraph invocation or renaming the two nodes that represent a model decision, which are supervisor and researcher. Without that, any dashboard grouping by observation type will not tell decision work apart from plumbing.
Instrumenting it without touching the repository
Open Deep Research ships as a LangGraph Server graph, with its langgraph.json pointing at deep_researcher. There are two ways to get the handler in, depending on how you run it.
If you invoke it from your own code, the compiled graph is importable and the config is enough:
from langfuse import Langfuse, propagate_attributes
from langfuse.langchain import CallbackHandler
from open_deep_research.deep_researcher import deep_researcher
trace_id = Langfuse.create_trace_id(seed=f"{request_id}")
handler = CallbackHandler(trace_context={"trace_id": trace_id})
with propagate_attributes(
trace_name="deep-research",
user_id=user,
session_id=request_id,
tags=["odr", "research"],
):
result = await deep_researcher.ainvoke(
{"messages": [HumanMessage(question)]},
config={
"callbacks": [handler],
"configurable": {
"thread_id": request_id,
"max_concurrent_research_units": 3,
"max_researcher_iterations": 4,
},
},
)
Lowering those two ceilings from 5 and 6 to 3 and 4 is not an observability recommendation, it is a product decision that also divides the trace volume by three. Better taken at the same time.
If you deploy it on LangGraph Server there is no invocation of yours to put the config into, so the handler gets bound at compile time. The pattern is to wrap the exported graph:
# instrumented_graph.py, and point langgraph.json here
from langfuse.langchain import CallbackHandler
from open_deep_research.deep_researcher import deep_researcher
graph = deep_researcher.with_config({"callbacks": [CallbackHandler()]})
That route loses the per-request seeded trace identifier, because the handler is created once. If the agent uses interrupts, that loss matters, for the reason the previous article measured.
GPT Researcher: two products in one repository
Here is the interesting part, and it is a trap you fall into with any agent that has grown in layers.
The repository holds the gpt_researcher package, which is the engine, and the multi_agents package, which is an orchestration on top. Only the second uses LangGraph.
The orchestrator (multi_agents/agents/orchestrator.py:66) has eight nodes: browser, planner, human, researcher, writer, fact_checker, visualizer and publisher, with two conditional loops, one for human review of the plan and one for fact checking. The researcher node launches in parallel one editor subgraph per section (multi_agents/agents/editor.py:134), and that subgraph has its own revision loop between reviewer and reviser.
All of that traces like any graph. And the models are called with self.llm.ainvoke(messages, **kwargs) from the generic provider (gpt_researcher/llm_provider/generic/base.py:365), which is a LangChain chat model with no explicit config, so it picks up the ambient configuration while running inside a node. The generations show up.
The standard mode is another thing. The GPTResearcher class has no graph, no nodes and does not go through LangGraph at any point. It is the mode the API and the web interface use, that is, the one most people run.
The myth of the fresh config
Before going on, something that looks like a bug and is not has to be dismantled, because it shows up in many agents.
When the researcher node launches the section subgraphs, it does it like this (multi_agents/agents/editor.py:74):
final_drafts = [
chain.ainvoke(self._create_task_input(research_state, query, title),
config={"tags": ["gpt-researcher"]})
for query in queries
]
research_results = [result["draft"] for result in await asyncio.gather(*final_drafts)]
That config is fresh and carries only tags. The method does not even receive the node’s configuration, so it could not forward it. The intuitive conclusion is that the subgraphs lose the callbacks and every section ends up in its own trace.
It does not happen. I set both patterns side by side, Open Deep Research’s forwarding the config and GPT Researcher’s passing a fresh one, and both give one trace with one root span. The reason is that LangChain does not take the config you pass as is: it starts from the ambient configuration held in a context variable and overlays the keys you bring. The callbacks are still there, and context variables are copied into the tasks asyncio.gather creates.
What does get lost are the parent’s tags, replaced by the new list. That is a filtering nuisance, not a broken trace.
The standard mode: eight calls, eight traces
With no graph there is no root run, and with no root run every model call is its own tree. Measured with eight standalone calls to a chat model, with the handler passed by hand on each one:
| Observations | 8 |
| Distinct traces | 8 |
| Root spans | 8 |
| Types | 8 generation |
Eight traces of one observation each, with no relation between them. None of them knows it belongs to the same piece of research. And that is the favourable case, because the standard mode offers no public place to put that handler: the **kwargs reach ainvoke from create_chat_completion (gpt_researcher/utils/llm.py:120), but the path crosses several internal layers that are not meant as an extension point.
The route that works without patching the repository is to wrap from outside with the SDK and let the inner calls hang off that:
from langfuse import get_client, observe, propagate_attributes
from gpt_researcher import GPTResearcher
@observe(name="gpt-researcher", as_type="agent")
async def research(question: str, request_id: str, user: str) -> str:
with propagate_attributes(user_id=user, session_id=request_id, tags=["gptr"]):
researcher = GPTResearcher(query=question, report_type="research_report")
await researcher.conduct_research()
report = await researcher.write_report()
client = get_client()
client.update_current_span(
output={"length": len(report), "sources": len(researcher.get_source_urls())},
metadata={"gptr_costs": researcher.get_costs()},
)
return report
With that there is one trace per piece of research, with an agent observation at the root and the generations hanging inside, in the same tree. Searches and document chunking do not appear, because they do not go through LangChain. If they are needed, they get added with start_as_current_observation(as_type="tool") around your own calls, which is the first of the three ways out of the span filter the previous article described.
Worth noting researcher.get_costs(). GPT Researcher keeps its own cost accounting, and putting it in as metadata is the only thing that lets you reconcile it later with what Langfuse computes. When the two figures disagree, it is almost always the gateway’s model alias.
The human node that stays open
The multi-agent mode has a human node that asks for an opinion on the plan before researching. It does not use LangGraph’s interrupt(). It waits inside the node, reading from the websocket (multi_agents/agents/human.py).
The two approaches have opposite consequences for the trace. With interrupt(), the graph hands control back and the trace splits in two, which is what the previous article measured. Waiting inside the node, the trace is single but that node’s observation stays open for as long as the person takes, minutes or hours.
Neither is wrong. What is wrong is the dashboard that averages durations without knowing which of the two it has in front of it. With the wait inside the node, the 95th percentile of graph duration measures how long a human takes to answer a message, not how long the agent takes. Alert on that metric and the alert fires at lunchtime.
The practical rule is to exclude human-wait nodes by name from any latency metric, and to measure that wait separately, which tends to be an interesting product metric in its own right.
What to look at in the trace once it arrives
With both agents instrumented, and before building any dashboard, there are four checks that are done once and save weeks:
- Count the observations of a typical request. If it comes out above a thousand, this platform’s problem is going to be volume and sampling has to be decided before anything else.
- Look for more than one root span. More than one means there is work being traced outside the tree, and you want to know which before there are thousands.
- Check that generations carry cost. If they come in at zero, it is the model alias, fixed by registering that name with its price.
- Sort observations by duration and remove the ones waiting on a person. What stays at the top is where the time really is.
Checklist
- The graph is invoked with a trace identifier seeded per request, not a random one.
- The agent’s concurrency and iteration ceilings are set deliberately, knowing they multiply trace volume.
- Nodes representing a model decision carry
agentin the name, or it has been accepted in writing that there will be no observations of that type. - Work that does not go through LangChain is wrapped with the SDK, or it has been decided that it need not be visible.
- Human-wait nodes are identified and excluded from latency metrics.
- The agent’s own cost accounting, if it has one, goes in as metadata so it can be reconciled.
- There is a measured figure of observations per request, taken from your own installation and not from this article.
Traps
A research agent’s defaults are demo values, not production ones. Five units times six iterations times ten react calls multiply, and whoever chose them was thinking about report quality, not about your ClickHouse.
One repository can hold two agents with two different instrumentation stories. Instrumenting the mode the README documents and not the one the API runs is a mistake with no symptom: the traces arrive, just of something else.
Passing a fresh config to a subgraph does not break the trace. It breaks the tags. The tree survives because the callbacks travel through a context variable.
A node waiting on a person contaminates any duration statistic. And not obviously, because the node finishes cleanly and flags no error.
Good node names and good observation classification are conflicting goals. The winner is whoever writes the agent’s code, who is normally not you.
Eight megabytes per request with three-hundred-character searches. With real results, the number your installation produces will not look like this article’s, and it will be larger.
The series: the ten articles
- What goes into a trace: version 4’s data model, limits, precedences, scores, masking and indexes.
- Putting LangGraph in front: instrumenting an agentic platform and the measured cost of one turn.
- Two well-known agents instrumented (this article): Open Deep Research and GPT Researcher, with the measured figures of a real request.
- Migrating from version 3 to 4 with no window: the three write-mode steps, the resumable background migrations and where the rollback point of no return sits.
- The worker queues: the map of all thirty-nine, which pool to dedicate to each group, the per-queue switches, sharding and concurrency.
- Real ClickHouse capacity and cost: how to measure bytes per observation with the system tables, the difference between the full table and the listings table, and the merge cost of full-text indexes.
- Retention, deletion and data protection: why a deletion does not free disk, the mask cleaner that ships disabled, the pending deletion queue and the S3 lifecycle that has to be implemented by hand.
- Backup and cross recovery: restore ordering across Postgres, ClickHouse and object storage, what each mismatch breaks, and how far event replay goes.
- Saturation runbook: what to alert on from the queue metrics, the stuck probes, draining through the readiness endpoint and the dead letter queue.
- Getting the data out: the blob storage integration to Parquet, batch exports and the metrics API, to build the data lake.
See also
- Putting LangGraph in front: the formula and the quadratic growth applied here to two real agents.
- Langfuse v4: what really goes into a trace: the data model that explains the cost of volume.
- Sizing the gateway for a fleet of agents: the same problem seen from the gateway.
- FinOps and GPU multi-tenancy with LiteLLM: why cost comes out at zero with a gateway alias.
- Durable execution and the cost of agents: what happens when the agent outlives the process.
- Isolating AI agents per customer: the isolation around an agent that browses and downloads.
Sources
- Open Deep Research source, commit
1b7d2e8, read on 14 September 2026:src/open_deep_research/deep_researcher.pyandconfiguration.py. - GPT Researcher source, commit
6f99857, read on 14 September 2026:multi_agents/agents/orchestrator.py,multi_agents/agents/editor.py,multi_agents/agents/human.py,gpt_researcher/llm_provider/generic/base.pyandgpt_researcher/utils/llm.py. - Own measurement of 14 September 2026 over replicas of both topologies, with LangGraph 1.2.11, langchain-core 1.6.3, Langfuse SDK 4.15.2,
InMemorySpanExporterand fake models, no network. - GitHub stars for both repositories, checked on 14 September 2026.