{"id":848,"date":"2026-04-02T00:28:17","date_gmt":"2026-04-02T00:28:17","guid":{"rendered":"https:\/\/canadatechnews.ca\/?p=848"},"modified":"2026-04-02T00:39:09","modified_gmt":"2026-04-02T00:39:09","slug":"same-brain-different-plumbing-langchain-vs-raw-sdk-why-i-stopped-hand-writing-json-at-2am","status":"publish","type":"post","link":"https:\/\/canadatechnews.ca\/?p=848","title":{"rendered":"Same Brain, Different Plumbing: LangChain vs Raw SDK (Why I Stopped Hand-Writing JSON at 2AM)"},"content":{"rendered":"\n<figure class=\"wp-block-gallery has-nested-images columns-default is-cropped wp-block-gallery-1 is-layout-flex wp-block-gallery-is-layout-flex\"><\/figure>\n\n\n\n<p>In AI engineering, people love asking which model is smarter. The better question is usually simpler: how much code did you have to write to make that model useful?<\/p>\n\n\n\n<p>This week, I rebuilt the same shopping assistant twice in my ReAct Udemy-course project. The first version uses LangChain abstractions. The second version uses raw Ollama SDK calls with manual tool schemas and provider-native message formatting. The model behavior is almost identical. The developer experience is not.<\/p>\n\n\n\n<p>Before comparing lines of code, it helps to define what this agent is actually doing. The loop both versions follow is the ReAct cycle, short for Reason + Act. The model reasons about the user request, decides whether it needs a tool, acts by calling that tool, reads the result, and then reasons again. This cycle repeats until the model has enough verified information to answer. In plain terms, the agent thinks, does, checks, and thinks again.<\/p>\n\n\n\n<p>That ReAct cycle is why both implementations feel similar at a high level. In both, we start with system and user context, ask the model for the next step, detect tool calls, execute one tool call, append the observation back to the conversation, and continue iterating. If no tool call is returned, we exit with the final answer. The algorithm is the same. What changes is how much scaffolding you manage manually.<\/p>\n\n\n\n<p>Here is the shared ReAct loop shape from both versions:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># LangChain (core loop shape)\nfor iteration in range(1, MAX_ITERATIONS + 1):\n    ai_message = llm_with_tools.invoke(messages)\n    tool_calls = ai_message.tool_calls\n    if not tool_calls:\n        return ai_message.content\n\n    tool_call = tool_calls&#091;0]\n    tool_name = tool_call.get(\"name\")\n    tool_args = tool_call.get(\"args\", {})\n    observation = tools_dict&#091;tool_name].invoke(tool_args)\n\n    messages.append(ai_message)\n    messages.append(ToolMessage(content=str(observation), tool_call_id=tool_call.get(\"id\")))<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code># Raw SDK (core loop shape)\nfor iteration in range(1, MAX_ITERATIONS + 1):\n    response = ollama_chat_traced(messages=messages)\n    ai_message = response.message\n    tool_calls = ai_message.tool_calls\n    if not tool_calls:\n        return ai_message.content\n\n    tool_call = tool_calls&#091;0]\n    tool_name = tool_call.function.name\n    tool_args = tool_call.function.arguments\n    observation = tools_dict&#091;tool_name](**tool_args)\n\n    messages.append(ai_message)\n    messages.append({\"role\": \"tool\", \"content\": str(observation)})<\/code><\/pre>\n\n\n\n<p>The first major difference appears at tool definition time. In LangChain, using <code>@tool<\/code> means the framework can generate the JSON schema from the function name, type hints, and docstring. In the raw SDK version, you write and maintain that schema yourself. It is not difficult, but it is repetitive, and repetition is where production bugs hide. Rename a parameter in Python and forget to update the schema dictionary, and now you have drift.<\/p>\n\n\n\n<p>Code comparison for tool definitions and schema handling:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># LangChain tools\nfrom langchain.tools import tool\n\n@tool\ndef get_product_price(product: str) -&gt; float:\n    \"\"\"Look up the price of a product in the catalog.\"\"\"\n    prices = {\"laptop\": 1299.99, \"headphones\": 149.95, \"keyboard\": 89.50}\n    return prices.get(product, 0)\n\n@tool\ndef apply_discount(price: float, discount_tier: str) -&gt; float:\n    \"\"\"Apply a discount tier to a price and return the final price.\n    Available tiers: bronze, silver, gold.\"\"\"\n    discount_percentages = {\"bronze\": 5, \"silver\": 12, \"gold\": 23}\n    discount = discount_percentages.get(discount_tier, 0)\n    return round(price * (1 - discount \/ 100), 2)<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code># Raw SDK tools + manual JSON schema\n@traceable(run_type=\"tool\")\ndef get_product_price(product: str) -&gt; float:\n    prices = {\"laptop\": 1299.99, \"headphones\": 149.95, \"keyboard\": 89.50}\n    return prices.get(product, 0)\n\n@traceable(run_type=\"tool\")\ndef apply_discount(price: float, discount_tier: str) -&gt; float:\n    discount_percentages = {\"bronze\": 5, \"silver\": 12, \"gold\": 23}\n    discount = discount_percentages.get(discount_tier, 0)\n    return round(price * (1 - discount \/ 100), 2)\n\ntools_for_llm = &#091;\n    {\n        \"type\": \"function\",\n        \"function\": {\n            \"name\": \"get_product_price\",\n            \"description\": \"Look up the price of a product in the catalog.\",\n            \"parameters\": {\n                \"type\": \"object\",\n                \"properties\": {\n                    \"product\": {\n                        \"type\": \"string\",\n                        \"description\": \"The product name, e.g. 'laptop', 'headphones', 'keyboard'\",\n                    }\n                },\n                \"required\": &#091;\"product\"],\n            },\n        },\n    }\n]<\/code><\/pre>\n\n\n\n<p>The second difference is message portability. LangChain\u2019s message classes (<code>SystemMessage<\/code>, <code>HumanMessage<\/code>, <code>ToolMessage<\/code>) give you a framework-level format that stays stable even when providers differ underneath. In the raw Ollama approach, you construct role\/content dictionaries directly in provider style. That is fine while you stay in one ecosystem, but the migration tax appears the minute you switch providers with slightly different expectations around message or tool payload shape.<\/p>\n\n\n\n<p>Code comparison for messages:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># LangChain messages\nmessages = &#091;\n    SystemMessage(content=\"You are a helpful shopping assistant...\"),\n    HumanMessage(content=question),\n]\n\nmessages.append(ai_message)\nmessages.append(ToolMessage(content=str(observation), tool_call_id=tool_call_id))<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code># Raw SDK (Ollama-style) messages\nmessages = &#091;\n    {\"role\": \"system\", \"content\": \"You are a helpful shopping assistant...\"},\n    {\"role\": \"user\", \"content\": question},\n]\n\nmessages.append(ai_message)\nmessages.append({\"role\": \"tool\", \"content\": str(observation)})<\/code><\/pre>\n\n\n\n<p>The third difference is invocation and execution safety. In LangChain, <code>bind_tools(...).invoke(...)<\/code> wraps the interaction in framework conventions that are easier to validate, trace, and debug at scale. In the raw path, you call <code>ollama.chat(...)<\/code> directly and execute tools with argument unpacking (<code>**tool_args<\/code>). This is flexible and fast for experimentation, but it places more responsibility on your own glue code and error handling discipline.<\/p>\n\n\n\n<p>Code comparison for model call and invocation:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># LangChain call path\nllm = init_chat_model(MODEL, model_provider=\"openai\", temperature=0)\nllm_with_tools = llm.bind_tools(tools)\nai_message = llm_with_tools.invoke(messages)<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code># Raw SDK call path\n@traceable(name=\"Ollama Chat\", run_type=\"llm\")\ndef ollama_chat_traced(messages):\n    return ollama.chat(model=MODEL, tools=tools_for_llm, messages=messages)\n\nresponse = ollama_chat_traced(messages=messages)\nai_message = response.message<\/code><\/pre>\n\n\n\n<p>Then there is tool-call structure itself. LangChain normalizes tool-call data into a consistent dictionary-like interface. Ollama\u2019s SDK returns typed objects with nested attributes. Neither is \u201cwrong,\u201d but one is portability-first and the other is provider-native. If your roadmap includes multiple model providers, that distinction is operational, not academic.<\/p>\n\n\n\n<p>Code comparison for tool-call parsing:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># LangChain\ntool_call = tool_calls&#091;0]\ntool_name = tool_call.get(\"name\")\ntool_args = tool_call.get(\"args\", {})\ntool_call_id = tool_call.get(\"id\")<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code># Raw SDK (Ollama typed object)\ntool_call = tool_calls&#091;0]\ntool_name = tool_call.function.name\ntool_args = tool_call.function.arguments<\/code><\/pre>\n\n\n\n<p>A related nuance is tool-result correlation. In some ecosystems, especially OpenAI-style tool calling, matching a tool result back to the exact tool call ID is required for correctness. In local sequential flows, you may get away without explicit IDs because calls are handled in order. LangChain smooths this cross-provider mismatch for you. In handmade loops, you own that compatibility layer.<\/p>\n\n\n\n<p>Code comparison for appending tool results:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># LangChain tool result correlation\nmessages.append(\n    ToolMessage(content=str(observation), tool_call_id=tool_call_id)\n)<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code># Raw SDK sequential append\nmessages.append(\n    {\"role\": \"tool\", \"content\": str(observation)}\n)<\/code><\/pre>\n\n\n\n<p>Observability follows the same pattern. In raw implementations, you often manually decorate and trace each boundary you care about. In framework-first implementations, tracing hooks are easier to apply consistently across the loop. If you have ever debugged a failing agent run at 1:30 AM, you already know this is not a small quality-of-life detail.<\/p>\n\n\n\n<p>Code comparison for tracing style:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># LangChain loop tracing\n@traceable(name=\"LangChain Agent Loop\")\ndef run_agent(question: str):\n    ...<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code># Raw SDK tracing split across tools + chat + loop\n@traceable(run_type=\"tool\")\ndef get_product_price(...):\n    ...\n\n@traceable(run_type=\"tool\")\ndef apply_discount(...):\n    ...\n\n@traceable(name=\"Ollama Chat\", run_type=\"llm\")\ndef ollama_chat_traced(messages):\n    ...\n\n@traceable(name=\"Ollama Agent Loop\")\ndef run_agent(question: str):\n    ...<\/code><\/pre>\n\n\n\n<p>The verdict is not that raw SDK code is bad. It is actually excellent for learning internals and keeping full control over every moving part. But for teams shipping features under deadlines, LangChain buys back time by removing repetitive plumbing and reducing cross-provider friction.<\/p>\n\n\n\n<p>So yes, both versions run the same ReAct cycle. They produce the same category of result. But one feels like driving, and the other feels like building the car while traffic is already moving.<\/p>\n\n\n\n<p>And sometimes, to be fair, building the car is exactly the point.<\/p>\n\n\n\n<p><strong>References<\/strong><\/p>\n\n\n\n<p>Udemy Course by Eden Marco<\/p>\n\n\n\n<p><a href=\"https:\/\/www.udemy.com\/course\/langchain\/\">LangChain- Agentic AI Engineering with LangChain &amp; LangGraph<\/a><\/p>\n\n\n\n<p>LangChain Docs \u2014 Tools<br><a href=\"https:\/\/docs.langchain.com\/oss\/python\/langchain\/tools\" target=\"_blank\" rel=\"noopener\" title=\"\">https:\/\/docs.langchain.com\/oss\/python\/langchain\/tools<\/a><\/p>\n\n\n\n<p>LangChain Docs \u2014 Messages<br><a href=\"https:\/\/docs.langchain.com\/oss\/python\/langchain\/messages\">https:\/\/docs.langchain.com\/oss\/python\/langchain\/messages<\/a><\/p>\n\n\n\n<p>Ollama Docs \u2014 Tool Calling<br><a href=\"https:\/\/docs.ollama.com\/capabilities\/tool-calling\">https:\/\/docs.ollama.com\/capabilities\/tool-calling<\/a><\/p>\n\n\n\n<p>Optional reference:<br>OpenAI Function Calling Guide<br><a href=\"https:\/\/platform.openai.com\/docs\/guides\/function-calling?api-mode=responses\">https:\/\/platform.openai.com\/docs\/guides\/function-calling?api-mode=responses<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>In AI engineering, people love asking which model is smarter. The better question is usually simpler: how much<\/p>\n","protected":false},"author":4,"featured_media":856,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[],"class_list":["post-848","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-editorial-culture"],"aioseo_notices":[],"_links":{"self":[{"href":"https:\/\/canadatechnews.ca\/index.php?rest_route=\/wp\/v2\/posts\/848","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/canadatechnews.ca\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/canadatechnews.ca\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/canadatechnews.ca\/index.php?rest_route=\/wp\/v2\/users\/4"}],"replies":[{"embeddable":true,"href":"https:\/\/canadatechnews.ca\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=848"}],"version-history":[{"count":4,"href":"https:\/\/canadatechnews.ca\/index.php?rest_route=\/wp\/v2\/posts\/848\/revisions"}],"predecessor-version":[{"id":857,"href":"https:\/\/canadatechnews.ca\/index.php?rest_route=\/wp\/v2\/posts\/848\/revisions\/857"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/canadatechnews.ca\/index.php?rest_route=\/wp\/v2\/media\/856"}],"wp:attachment":[{"href":"https:\/\/canadatechnews.ca\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=848"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/canadatechnews.ca\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=848"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/canadatechnews.ca\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=848"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}